使用d3.js开发力导向图

6 篇文章 0 订阅

最近项目需要写一个d3的力导向图,之前没接触过d3.js 所以吧这次开发的经历写一下

效果图2

友情提示:不要让设计设计的华丽呼哨,点多了很卡,而且svg 有些标签是不支持css 控制 某些样式的,也不是很好实现

如果之前没写过d3 的旁友 还不熟悉d3 的话。可以吧d3. js 理解为1个帮助你处理数据的库。

把点与线 给到d3, d3 会根据你传入的长宽 自动给你分配x,y 的位置,自己再通过 js 去点的位置想干嘛干嘛(画点)

###基本的展示

####基本配置


// 生成力

const force = d3

        .forceSimulation()

        .force('link',d3.forceLink().id((d) => d.id),)

        .force('collide', d3.forceCollide(72).strength(0.1))

        .force('charge',d3.forceManyBody().strength(-400),)

        .force('center', d3.forceCenter());

分配点与线

处理一下线的数据, 两个点可能出现多条线的情况


export const setLinkNumber = (group, type) => {

  if (group.length === 0) return;

  

  const linksA = [];

  const linksB = [];

  for (let i = 0; i < group.length; i++) {

    const link = group[i];

    // 对该分组内的关系按照方向进行分类,此处根据连接的实体ASCII值大小分成两部分

    if (link.source.id < link.target.id) {

      linksA.push(link);

    } else {

      linksB.push(link);

    }

  }

  // 确定关系最大编号。为了使得连接两个实体的关系曲线呈现对称,根据关系数量奇偶性进行平分。

  // 特殊情况:当关系都是连接到同一个实体时,不平分

  let maxLinkNumber = 0;

  if (type === 'self') {

    maxLinkNumber = group.length;

  } else {

    maxLinkNumber = group.length % 2 === 0 ? group.length / 2 : (group.length + 1) / 2;

  }

  // 如果两个方向的关系数量一样多,直接分别设置编号即可

  if (linksA.length === linksB.length) {

    let startLinkNumber = 1;

    for (let i = 0; i < linksA.length; i++) {

      linksA[i].linknum = startLinkNumber++;

    }

    startLinkNumber = 1;

    for (let i = 0; i < linksB.length; i++) {

      linksB[i].linknum = startLinkNumber++;

    }

  } else {

    // 当两个方向的关系数量不对等时,先对数量少的那组关系从最大编号值进行逆序编号,然后在对另一组数量多的关系从编号1一直编号到最大编号,再对剩余关系进行负编号

    // 如果抛开负号,可以发现,最终所有关系的编号序列一定是对称的(对称是为了保证后续绘图时曲线的弯曲程度也是对称的)

    let biggerLinks;

    let smallerLinks;

    if (linksA.length > linksB.length) {

      biggerLinks = linksA;

      smallerLinks = linksB;

    } else {

      biggerLinks = linksB;

      smallerLinks = linksA;

    }



    let startLinkNumber = maxLinkNumber;

    for (let i = 0; i < smallerLinks.length; i++) {

      smallerLinks[i].linknum = startLinkNumber--;

    }

    const tmpNumber = startLinkNumber;



    startLinkNumber = 1;

    let p = 0;

    while (startLinkNumber <= maxLinkNumber) {

      biggerLinks[p++].linknum = startLinkNumber++;

    }

    // 开始负编号

    startLinkNumber = 0 - tmpNumber;

    for (let i = p; i < biggerLinks.length; i++) {

      biggerLinks[i].linknum = startLinkNumber++;

    }

  }

};

function getKey(target, source) {

  const result = target > source ? `${target}:${source}` : `${source}:${target}`;

  return result;

}

export const operationData = (chartData, clickType) => {

  const linkmap = {};

  const linkGroup = {};

  const { links, dots } = chartData;

  for (let i = 0; i < links.length; i++) {

    const link = links[i];

    const { target, source } = link;

    const key = getKey(target, source);



    if (linkGroup[key]) {

      linkGroup[key].push(link);

      linkmap[key] += 1;

    } else {

      linkGroup[key] = [links[i]];

    }

  }

  Object.keys(linkGroup).forEach((groupKey) => {

    linkmap[groupKey] = linkGroup[groupKey].length;

  });

  // 关联线与点

  JSON.parse(JSON.stringify(links)).forEach((e) => {

    const sourceNode = dots.filter((n) => n.id === e.source)[0];

    const targetNode = dots.filter((n) => n.id === e.target)[0];

    const nowIndex = links.findIndex(

      (n) => n.source === e.source && n.target === e.target,

    );

    if (!sourceNode || !targetNode) {

      links.splice(nowIndex, 1);

    } else {

      links[nowIndex].source = sourceNode;

      links[nowIndex].target = targetNode;

    }

  });

  for (let i = 0; i < links.length; i++) {

    let { target, source } = links[i];

    target = target.id;

    source = source.id;

    const link = links[i];

    const key = getKey(target, source);

    link.size = linkmap[key];

    const group = linkGroup[key];

    const type = 'noself'; // 标示该组关系是指向两个不同实体还是同一个实体

    setLinkNumber(group, type);

  }

  return { links, dots };

};

处理好的数据丢到d3里面去


// tick 渲染时执行的方法

force.nodes(dots).alpha(0.01).on('tick', this.tick).restart();

force.force('link').links(links).distance(150);

丢进去 会吐出来有x,y 的数据 如:

数据处理完了 接下来创建dom

创建dom
线

线: 线是一个 g 标签包含着N 条线(a)

线内包含着2条线以及线相关的箭头

两条线的目的是因为1条线很细的情况下会很不好hover 到。所以1条粗线 一条细线 ,直接把透明度(opacity) 属性 设置为0 即可

所有箭头状态的属性,因为箭头和线不是"一体"的,所以当hover 的时候,圆点(dot) 的大小会发生变化,箭头的refX,refY,也会发生变化,甚至 箭头的大小变化 refX 和refY也得改变。


export const styleSize = {

  normal: {

    refX: 30,

    markerHeight: 8,

    markerWidth: 8,

  },

  hover: {

    refX: 28,

    markerHeight: 10,

    markerWidth: 10,

  },

  click: {

    refX: 19,

    markerHeight: 17.5,

    markerWidth: 17.5,

  },

  dotnormal: {

    refX: 35,

    markerHeight: 8,

    markerWidth: 8,

  },

  dothover: {

    refX: 43,

    markerHeight: 10,

    markerWidth: 10,

  },

  dotclick: {

    refX: 48,

    markerHeight: 10,

    markerWidth: 10,

  },

  dotlineclick: {

    refX: 28,

    markerHeight: 25,

    markerWidth: 20,

  },

};



// isThumb 是否是缩略图

export const drawLine = (svg, type, links) => {

  const isThumb = type === 'thumb';

  const warp = isThumb ? svg.insert('g', '.dragThumb') : svg.append('g');

  const lineWarp = warp

    .attr('class', `${isThumb ? 'thumbG' : 'forceLines forceMainG'}`)

    .selectAll('g')

    .data(links)

    .enter()

    .append('a')



  const {

    refX, markerWidth, markerHeight,

  } = styleSize.normal;

  const markerId = (d) => `marker-${(d.id)}`;

  lineWarp

    .append('marker')

    .attr('id', markerId)

    .attr('markerUnits', 'userSpaceOnUse')

    .attr('viewBox', '0 -5 10 10') // 坐标系的区域

    .attr('refX', refX) // 箭头坐标

    .attr('markerWidth', markerWidth) // 标识的大小

    .attr('markerHeight', markerHeight)

    .attr('orient', 'auto') // 绘制方向,可设定为:auto(自动确认方向)和 角度值

    .attr('stroke-width', 2) // 箭头宽度

    .append('path')

    .attr('d', 'M0,-5L10,0L0,5') // 箭头的路径

    .attr('fill', 'inherit'); //箭头的颜色, 设置箭头的颜色 不可以直接找到箭头然后更改fill 因为真正有颜色的是 箭头里面的dom

    

  // 展示的线

  const line = lineWarp.append('path')

    //实线虚线自己控制

    .attr('stroke-dasharray', (d) => (虚线 ? '8,5' : ''))

    .attr('marker-end', (d) => {

      if (isThumb) return '';

      return `url(#${(markerId(d))})`;

    });

    // 实际hover 以及 点击的线

  const bkLine = lineWarp.append('path')

    .attr('stroke-width', 10)

    .attr('stroke', 'red')

    .attr('fill', 'none')

    .attr('opacity', '0')

  return {

    lineWarp,

    line,

    bkLine,

  };

};

此处只创建1个圆点


export const drawCircle = (svg, nodes, type) => {

  const dotWarp = svg

    .append('g')

    .attr('class', 'forceNodes forceMainG')

    .selectAll('g')

    .data(nodes)

    .enter()

    .append('a')

    .attr('xlink:href', 'javascript:void(0)');

  const circle = dotWarp.append('circle')

    .attr('class', 'forceNode regionNode');

  return {

    circle, dotWarp

  };

};

绘制线

在之前讲的tick 中去改变 path 的d属性 以及 点的 位置


this.paths.attr('d', function (data) {

return pathD(data, this);

});

this.bkLine.attr('d', function (data) {

return pathD(data, this);

});

this.dotWarp.attr('transform', setTransform); // 圆圈




export const pathD = (d, dom) => {

  const { x: sx, y: sy } = d.source;

  const { x: tx, y: ty } = d.target;

  let dr;

  // 如果连接线连接的是同一个实体,则对path属性进行调整,绘制的圆弧属于长圆弧,同时对终点坐标进行微调,避免因坐标一致导致弧无法绘制

  if (d.target === d.source) {

    dr = 30 / d.linknum;

    return (

      `M${

        sx

      },${

        sy

      }A${

        dr

      },${

        dr

      } 0 1,1 ${

        tx

      },${

        ty + 1}`

    );

  } if (d.size % 2 !== 0 && d.linknum === 1) {

    // 如果两个节点之间的连接线数量为奇数条,则设置编号为1的连接线为直线,其他连接线会均分在两边

    return `M ${sx},${sy},L ${tx},${ty}`;

  }

  // 根据连接线编号值来动态确定该条椭圆弧线的长半轴和短半轴,当两者一致时绘制的是圆弧

  // 注意A属性后面的参数,前两个为长半轴和短半轴,第三个默认为0,

  // 第四个表示弧度大于180度则为1,小于则为0,这在绘制连接到相同节点的连接线时用到;

  // 第五个参数,0表示正角,1表示负角,即用来控制弧形凹凸的方向。本文正是结合编号的正负情况来控制该条连接线的凹凸方向,从而达到连接线对称的效果



  const curve = 1.5;

  const homogeneous = 2;

  const dx = d.target.x - d.source.x;

  const dy = d.target.y - d.source.y;

  dr = (Math.sqrt(dx * dx + dy * dy) * (d.linknum + homogeneous))

      / (curve * homogeneous);

  // 当节点编号为负数时,对弧形进行反向凹凸,达到对称效果

  if (d.linknum < 0) {

    if (dom) {

      d3.select(dom.previousElementSibling).attr('refY', 4).attr('oldRefY', 4);

    }

    dr = (Math.sqrt(dx * dx + dy * dy) * (-1 * d.linknum + homogeneous))

      / (curve * homogeneous);

    return (

      `M${sx},${sy}A${dr},${dr} 0 0,0 ${tx},${ty}`

    );

  }

  if (dom) {

    d3.select(dom.previousElementSibling).attr('refY', -4).attr('oldRefY', -4);

  }

  return `M${sx},${sy}A${dr},${dr} 0 0,1 ${tx},${ty}`;

};






export const setTransform = (node) => {

  const { x, y, k } = node;

  let result = '';

  if (x && y)result += `translate(${x},${y})`;

  if (k)result += ` scale(${k})`;

  return result;

};

####点的拖拽

固定节点的方法就是 设置fx fy=null


// 创建完dotWarp的时候 可以直接绑定

      dotWarp.call(

        d3

          .drag()

          .on('start', this.started)

          .on('drag', this.dragged)

          .on('end', this.ended),

      );

      

    started(d) {

      const { force } = this;

      if (!d3.event.active) {

        force.alphaTarget(0.2).restart(); // 设置衰减系数,对节点位置移动过程的模拟,数值越高移动越快,数值范围[0,1]

      }

      d.fx = d.x;

      d.fy = d.y;

    },

    dragged(d) {

      const { x, y } = d3.event;

      if (this.inBoundaries(x, y).isIn) {

        d.fx = x;

        d.fy = y;

      }

      d.fx = d3.event.x;

      d.fy = d3.event.y;

    },

    ended(d) {

      const { force } = this;

      if (!d3.event.active) {

        force.alphaTarget(0);

      }

      d.fx = null;

      d.fy = null;

    },

####zoom 画布的拖拽 以及放大缩小




  /**

   * @params

   * zoomMin 最小缩小倍数

   * zoomMax 最大放大倍数

   */

const zoom = d3

        .zoom()

        .scaleExtent([zoomMin, zoomMax])

        .on('zoom', () => {

          const transInfo = d3.event.transform; //绘制框选的时候需要用到

          mainSvg.selectAll('g').attr('transform', transInfo);

          this.transInfo = transInfo;

          this.$emit('zoom', transInfo); // 告诉外层 发生了拖拽

        });

      mainSvg.call(zoom).on('dblclick.zoom', null); // 注销双击缩放

放大 缩小


//svg.transition().duration(750).call(zoom.scaleBy,放大的倍数);

// 缩小 0.9倍直到 缩小到最小倍数

svg.transition().duration(750).call(zoom.scaleBy, 0.9);

// 放大 1.1倍直到 放大到最大倍数

svg.transition().duration(750).call(zoom.scaleBy, 1.1);

####点的框选

拖拽中创建一个矩形框,拖拽后判断中心点是否在矩形框中则为被框选中 (位置需要与缩放的scale 配合计算)

####删除

点的删除实际上 就是把 相关点与线全部删除, 并且清空画布后, 重新用删除后的数据重新绘制。

####缩略图

缩略图目前的逻辑是主图的最大倍数作为背景,主图的宽高作为缩略图视野(蓝框)的宽高。

因为缩略图的dom 的宽高是css 定死的,所以给定主图(正常)的宽高 会自动缩放。

主图的拖拽与缩略图背景图的关系会在下面一节说


      /**

       * @params

       * width 缩略图宽度

       * height 缩略图高度

       * mainWidth 主图的宽度

       * mainHeight 主图的高度

       * zoomMax 最大缩放比例

       * 

       */

          thumbSvg = d3

            .select('#thumbWarp')

            .append('svg');

          dragThumb = thumbSvg.append('rect')

            .attr('class', 'dragThumb')

            .attr('fill', 'none');

        let w; let h; let x = 0; let y = 0;

        thumbSvg.attr('width', width)

          .attr('height', height)

          .attr('id', 'thumbSvg')

          .attr('viewBox', () => {

          // 缩略图的宽高为 主图的 最大缩略比例

            w = mainWidth * zoomMax;

            h = mainHeight * zoomMax;

            // 设置偏移 让背景图移至中心,缩略图与主图的差/ 2 就是需要移动的距离

            x = -(w - mainWidth) / 2;

            y = -(h - mainHeight) / 2;

            return `${x} ${y} ${w} ${h}`;

          });

        dragThumb.attr('width', mainWidth)

          .attr('height', mainHeight);

####主图的拖拽、缩放与缩略图

主图的拖拽与缩放 在调用上面的缩放的时候会调用zoom 的on zoom 方法

并将缩放以及拖拽的距离传给 缩略图

因为缩放会造成 主图的 translate 发生变化 与手动拖拽造成的translate 会有差 所以 要扣除缩放造成的偏移


      if (!mainTransform.x && !mainTransform.y && mainTransform.k === 1) {

        this.initSvg();

        return;

      }

      const {

        innerZoomInfo, mainWidth, mainHeight,

      } = this;

      // 如果传入的 缩放值与之前记录的缩放值不一致 则认为发生了缩放 记录发生缩放后偏移值

      if (!innerZoomInfo || innerZoomInfo.k !== mainTransform.k) {

        this.moveDiff = {

          x: (mainWidth - innerZoomInfo.k * mainWidth) / 2,

          y: (mainHeight - innerZoomInfo.k * mainHeight) / 2,

        };

      }

      const { x: diffX, y: diffY } = this.moveDiff;

      const { x, y, k } = mainTransform; // 主图偏移以及缩放数据

      this.dragThumb

        .attr('width', mainWidth / k)

        .attr('height', mainHeight / k)

        .attr('transform', () => setTransform({

          x: -((x - diffX) / k), // 这个地方应该不能直接 除 k 这里的x,y 应该是放大后的x,y应该减去缩放的差值 再 除K

          y: -((y - diffY) / k),

        }));

###自己实现一个简单的拓扑图

####碰撞检测

一开始的逻辑,两个正方形任意正方形包裹住另外一个任意一点则为碰撞 如下图。如果画的真正是个圆形的话则存在精度不足的问题

但是这种情况不适于 两个长方形只相交,如:

最后还是需要改为两个圆进行检测,逻辑为任意两个圆形的圆心距离是否小于两圆半径之和,若小于则为碰撞。


Math.sqrt(Math.pow(circleA.x - circleB.x, 2) +

          Math.pow(circleA.y - circleB.y, 2)) 

    < circleA.radius + circleB.radius

详情见 aotu实验室 碰撞专栏

####点的分配

点的位置的分配 就是确定中心点后,将关系最多的点作为中心点,其关系点向四周分散,没有关系的同级点,则向中心点四周进行分散,其关系点以确定后位置的点的坐标向周围分散。

根据三角形的正玄、余弦来得值;

假设一个圆的圆心坐标是(a,b),半径为r,角度为d

则圆上每个点的坐标可以通过下面的公式得到


/*

* @params

* d 角度

* r 半径长度

*/

X = a + Math.cos(((Math.PI * 2) / 360) * d) * r;

Y = b + Math.sin(((Math.PI * 2) / 360) * d) * r;

角度可以通过 关系边进行得到. d = 360/关系边的数量,确定第一圈点的角度。

拿到角度后 ,维持一个所有点坐标的对象,再结合碰撞上门的碰撞检测,我们就可以遍历 获取所有点的坐标了


/*

* @params

* dotsLocations 所有点的坐标信息

*/

initNodes() {

    const { x: centerX, y: centerY } = this.center;

    const { distance } = this;

    const getDeg = (all, now) => 360 / (all - (now || 0));

    // 把中心点分配给线最多的点

    const centerdot = this.dots[0];

    centerdot.x = centerX;

    centerdot.y = centerY;

    this.dotsLocations[centerdot.id] = { x: centerX, y: centerY };

    this.dots.forEach((dot) => {

      const { x: outx, y: outy } = dot;

      if (!outx && !outy) {

      // 兄弟点 (无关系的点) 默认以中心店的10度进行遍历

        dot = this.getLocation(dot, centerX, centerY,10, distance).dot;

      }

      const { x: cx, y: cy } = dot;

      const dotsLength = dot.relationDots.length;

      let { distance: innerDistance } = this;

      // 获取剩余点的角度

      let addDeg = getDeg(dotsLength);

      dot.relationDots.forEach((relationId, index) => {

        let relationDot = this.findDot(relationId);

        if (!relationDot.x && !relationDot.y) {

          const {

            dot: resultDot,

            isPlus,

            outerR,

          } = this.getLocation(relationDot, cx, cy, addDeg, innerDistance);

          if (isPlus) {

          // 如果第一圈遍历完毕,则开始以 半径 * 2 为第二圈开始遍历

            innerDistance = outerR;

            addDeg = getDeg(dotsLength, index);

            addDeg += randomNumber(5, 9);  //防止第一圈与第二圈的点所生成的角度一致 造成链接的线重叠在一起

          }

          relationDot = resultDot;

        }

      });

    });

  }



  


// 分配位置

  getLocation(dot, cx, cy, addDeg, distance) {

  // 由第一张图 得知 -90度为最上面  从最上面开始循环

    let outerDeg = -90;

    let outerR = distance;

    const { distance: addDistance } = this;

    let firsted; // 用于分布完后一周

    while (Object.keys(this.checkDotLocation(dot)).length !== 0) {

      outerDeg += addDeg;

      if (outerDeg > 360) {

      // 转完一圈 随机生成第二圈的角度再开始对当前点进行定位

        addDeg = randomNumber(10, 35);

        outerDeg = addDeg;

        if (firsted) {

          outerR += addDistance;

        }

        firsted = true;

      }

      const innerLocation = getDegXy(cx, cy, outerDeg, outerR);

      dot = Object.assign(dot, innerLocation);

    }

    this.dotsLocations[dot.id] = { x: dot.x, y: dot.y };

    return {

      dot,

      isPlus: firsted,

      outerR,

    };

  }


  // 碰撞检测

  checkDotLocation(circleA) {

    let repeat = false;

    if (!circleA.x || !circleA.y) return true;

    const { forceCollide } = this;

    console.log(this.dotsLocations)

    Object.keys(this.dotsLocations).forEach((key) => {

      if (key === circleA.id) {

        return;

      }

      const circleB = this.dotsLocations[key];

      let isRepeat = Math.sqrt(Math.pow(circleA.x - circleB.x, 2) + Math.pow(circleA.y - circleB.y, 2)) < forceCollide * 2;

      if(isRepeat)repeat = true;

    });

    return repeat;

  }

}

生成时间与D3 的差不多

####碰撞后点的移动 (力?)

碰撞后的逻辑呢 简单的就是已拖动点为圆点,计算碰撞点与圆点的夹角,再通过角度与距离得出碰撞后被碰撞点的x,y的坐标


changeLocation(data, x, y, eliminate) {

// 先对原来的点进行赋值

    data.x = x;

    data.y = y;

    // 对点的坐标进行赋值,使之后的碰撞使用新值进行计算

    this.dotsLocations[data.id] = { x, y };

    let crashDots = this.checkDotLocation(data);

    // 获得所有被碰撞的点

    Object.keys(crashDots).forEach((crashId) => {

      if (eliminate === crashId) return; // 碰撞后的碰撞防止 更改当前拖拽元素

      const crashDot = this.findDot(crashId);

      // 获取被碰撞的x,y 值

      const { x: crashX, y: crashY } = crashDot;

      // 此处的角度是要移动的方向的角度

      let deg = getDeg(crashDot.x,crashDot.y,data.x,data.y);

      // - 180 的目的是为了 与上面的黑图角度一致

      // 2是碰撞后  移动2个像素的半径

      const {x:endX,y:endY} = getDegXy(crashDot.x, crashDot.y, deg - 180, 2);

      // 讲被碰撞的点作为圆点 改变值 并进行碰撞点的碰撞的碰撞检测(禁止套娃 )

      this.changeLocation(crashDot, endX, endY, data.id);

    });

  }

获取夹角角度


function getDeg(x1,y1,x2,y2){

  //中心点

  let cx = x1;

  let cy = y1;



  //2个点之间的角度获取

  let c1 = Math.atan2(y1 - cy, x1 - cx) * 180 / (Math.PI);

  let c2 = Math.atan2(y2 - cy, x2 - cx) * 180 / (Math.PI);

  let angle;

  c1 = c1 <= -90 ? (360 + c1) : c1;

  c2 = c2 <= -90 ? (360 + c2) : c2;



  //夹角获取

  angle = Math.floor(c2 - c1);

  angle = angle < 0 ? angle + 360 : angle;

  return angle;

}

到此实现一个简单的拓扑图就搞定了。

使用我们自己的force 代替 d3.js 的效果,后期想要什么效果就可以自己再加了 如 拖动主点相关点动,其他关联点不动的需求。

tick方法需要自己手动去调用了


let force = new Force({

          x: svgW / 2,

          y: svgH / 2,

          distance: 200,

          forceCollide:30,

        });

        force.nodes(dot);

        force.initLines(line);

####拖动

这边的tick 是当 点的xy 发生变化的时候 自己去重新构建点和线。再实际项目中每一次拖动就会构建,会比较卡,可以丢到requestAnimationFrame 去调用


        dotDoms.on("mousedown", function (d) {

          dragDom = {

            data: d,

            dom: this,

          };

        });

        d3.select("svg").on("mousemove", function (d) {

          if (!dragDom) return;

          const { offsetX: x, offsetY: y } = d3.event;

          if (x < -1 || y < -1 || x >= svgH - 10 || y >= svgH - 10) {

            //边界

            dragDom = null;

            return;

          }

          force.changeLocation(dragDom.data, x, y);

          tick();

        });

        d3.select("svg").on("mouseup", function (d) {

          dragDom = null;

        });

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值