echarts d3比较_技术人员眼中的BI之可视化 —— 艺术家:D3

本文介绍了D3.js库的使用,通过一个世界人口对比的实例,详细讲解了从获取数据、数据转化到使用比例尺在SVG上绘制柱状图的过程。D3的特点是表达能力强且底层,能实现复杂的可视化效果。文章涵盖了数据获取、数据转化、比例尺应用、SVG操作等内容,展示了D3在可视化领域的灵活性和艺术性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

可视化的经典著作

如上一篇聊ECharts时所说,用ECharts只回答了HOW的问题,并没有回答WHY的问题。要回答WHY的问题,我们就需要有理论来指引了。

可视化的一本经典之作就是:《The Grammar of Graphics》(《图形语法》)这本书了:

f5f5d481e658e024afe3e60e7203fe3c.png

此书作为经典,指引了很多图形库的设计。当然对于值得我们尊敬的经典著作,我肯定不期望能在一篇公众号的文章里就能描述清楚,还请有志于深入研究可视化的朋友们自行研读。

本文主要是从更实用的角度,用另一个非常著名的 D3 可视化JavaScript库来让大家体验一下可视化的魅力。

D3

D3,全名是:Data-Driven Documents,项目地址在:https://d3js.org/

7ed8025355babe13034fc7ea7ee1f830.png

项目的历史我就不再赘述了,大家可以自行搜索 D3 (也可以顺便了解一下它的创建者 Mike Bostock https://bost.ocks.org/mike/)

D3的特点:

  • 表达能力非常强大
  • 非常底层(如果类比编程语言的话, D3可以算是C语言,SVG是汇编语言)

由于其太底层又博大精深,所以比较难在文章中写清。所以,我也犹豫了很久要不要写D3,不过考虑到D3在可视化领域的“泰山北斗”地位,如果写可视化而不写D3,那将会是不完整的。So, 让我们开始D3之旅。

Step 0. 准备工作

本文中的例子是和前文ECharts中同样的例子:”世界人口总量“在2011年和2012年的对比。方便大家来体会EChart和D3的不同。

建立一个 d3.html 文件,内容如下:

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <title>D3 Demo</title>
  6. <script src="https://d3js.org/d3.v5.js"></script>
  7. </head>
  8. <body>
  9. <svg></svg>
  10. </body>
  11. <script>
  12. </script>
  13. </html>

后续的所有D3代码将动态修改 <svg></svg> 中的内容

Step 1. 获取数据

首先,我们把数据(前文ECharts中的例子)存为csv文件,作为样例数据

yearcountrypopulation2011巴西182032012巴西193252011印尼234892012印尼234382011美国290342012美国310002011印度1049702012印度1215942011中国1317442012中国1341412011世界人口6302302012世界人口681807

接下来, 我们通过 d3.csv 函数来获取和解析CSV中的数据,以供后续使用。

由于CSV中,默认每个值都是文本类型,而我们知道 year 和 population 是数值类型,那么我们可以在 d3.csv 的第二个参数(回调函数)中做类型转化:

  1. let dataset = await(d3.csv("d3data.csv", function(d) {
  2. return {
  3. year: +d.year,
  4. country: d.country,
  5. population: +d.population
  6. };
  7. }));

获取到如下方便 D3 操作的数据:

  1. [
  2. {"year": 2011, "country": "巴西", "population": 18203},
  3. {"year": 2012, "country": "巴西", "population": 19325},
  4. {"year": 2011, "country": "印尼", "population": 23489},
  5. {"year": 2012, "country": "印尼", "population": 23438},
  6. {"year": 2011, "country": "美国", "population": 29034},
  7. {"year": 2012, "country": "美国", "population": 31000},
  8. {"year": 2011, "country": "印度", "population": 104970},
  9. {"year": 2012, "country": "印度", "population": 121594},
  10. {"year": 2011, "country": "中国", "population": 131744},
  11. {"year": 2012, "country": "中国", "population": 134141},
  12. {"year": 2011, "country": "世界人口", "population": 630230},
  13. {"year": 2012, "country": "世界人口", "population": 681807}
  14. ]

Step 2. 数据转化

《图形语法》一书中提到了三种数据转化操作:

  • cross:有点类似SQL中的笛卡尔积
  • blend:有点类似SQL中的Union
  • nest:有点类似SQL中的Group By,但是不一样

本例中,由于本例中数据是单表,我们只需要用 nest 操作,D3中的相关文档:https://github.com/d3/d3-collection/blob/v1.0.7/README.md#nest

我们先来看一下简单的对国家做 nest 操作会得到什么结果?

  1. d3.nest()
  2. .key(d => d.country)
  3. .entries(dataset)

得到结果:

  1. [
  2. {
  3. "key": "巴西",
  4. "values": [
  5. {"year": 2011, "country": "巴西", "population": 18203},
  6. {"year": 2012, "country": "巴西", "population": 19325}
  7. ]
  8. },
  9. {
  10. "key": "印尼",
  11. "values": [
  12. {"year": 2011, "country": "印尼", "population": 23489},
  13. {"year": 2012, "country": "印尼", "population": 23438}
  14. ]
  15. },
  16. {
  17. "key": "美国",
  18. "values": [
  19. {"year": 2011, "country": "美国", "population": 29034},
  20. {"year": 2012, "country": "美国", "population": 31000}
  21. ]
  22. },
  23. {
  24. "key": "印度",
  25. "values": [
  26. {"year": 2011, "country": "印度", "population": 104970},
  27. {"year": 2012, "country": "印度", "population": 121594}
  28. ]
  29. },
  30. {
  31. "key": "中国",
  32. "values": [
  33. {"year": 2011, "country": "中国", "population": 131744},
  34. {"year": 2012, "country": "中国", "population": 134141}
  35. ]
  36. },
  37. {
  38. "key": "世界人口",
  39. "values": [
  40. {"year": 2011, "country": "世界人口", "population": 630230},
  41. {"year": 2012, "country": "世界人口", "population": 681807}
  42. ]
  43. }
  44. ]

我们可以看出经过简单的 nest + entries, 我们会把原始数组,根据key拆分为多个子数组。

我们第一个例子先做一个单柱图,所以,我们需要的数据是:各个国家两年内的平均人口数。我们接下来用nest的 rollup 子操作:

  1. let meanDataset = d3.nest()
  2. .key(d => d.country)
  3. .rollup(d => d3.mean(d.map(x => x.population)))
  4. .entries(dataset)

得到如下结果,并存为 meanDataset

  1. [
  2. {"key": "巴西", "value": 18764},
  3. {"key": "印尼", "value": 23463.5},
  4. {"key": "美国", "value": 30017},
  5. {"key": "印度", "value": 113282},
  6. {"key": "中国", "value": 132942.5},
  7. {"key": "世界人口", "value": 656018.5}
  8. ]

Step 3. D3操作SVG

数据有了,我们现在要开始考虑怎么在图中画出来了

D3可以操作各种 HTML Element,当然对于图形来说,最简单的是操作 SVG 元素。

SVG是一种矢量图形标准,它的英文全称为Scalable Vector Graphics,是W3C定义的标准。

SVG比较需要注意的是其坐标系,是从左上角为 (0,0) 原点的,

cc6bbe8887c139d7e5dcccdc0a84b74b.png

比如:我们现在SVG中画个圆,那么我们可以这样写:

  1. <svg width="400px" height="300px">
  2. <circle cx=50 cy=50 r=50 style='fill:#3EB9BA'>
  3. </circle>
  4. </svg>

将在 (50,50) 这个坐标为圆心,画一个半径为50的圆,并把圆的填充颜色设置为好看的“观远”色

D3 操作 HTML Elements 主要是 d3-selection 子项目,比如:d3.select 和 d3.selectAll, 可以看文档:https://github.com/d3/d3-selection/blob/v1.4.1/README.md#select

我们用D3来实现往之前 d3.html 中预留的 <svg></svg> 节点添加一个圆形的代码如下:

  1. let width = 400
  2. let height = 300
  3. let svg = d3.select("svg")
  4. .attr("width", width)
  5. .attr("height", height)
  6. svg.append("circle")
  7. .attr("cx", 50)
  8. .attr("cy", 50)
  9. .attr("r", 50)
  10. .style("fill", "#3EB9BA")

d3.select("svg") 是通过 前端的 CSSSelector 来选择对应的HTML Element,结果和手工写SVG是一样的:

Step 4. 使用比例尺 (Scale)来映射实际数据到屏幕坐标

Step3中,我们可以操作简单的SVG元素了,但是毕竟我们要做的图不是随便拼接起来的,而是要和实际数据有关的统计图形,那么我们需要比例尺(Scale)的支持。

在《图形语法》书中,有一章来讲 Scales,对于D3来说,也有很多API是和比例尺相关。

比例尺的作用是什么呢?

比如:我们有了数据meanDataset

  1. [
  2. {"key": "巴西", "value": 18764},
  3. {"key": "印尼", "value": 23463.5},
  4. {"key": "美国", "value": 30017},
  5. {"key": "印度", "value": 113282},
  6. {"key": "中国", "value": 132942.5},
  7. {"key": "世界人口", "value": 656018.5}
  8. ]

我们想要做一个“垂直单柱图”,那么我们的X轴是“国家”,Y轴是“人口数”。首先我们遇到的第一个问题是:我们如何把各个数据转化为SVG画布上的坐标。这个就是比例尺(Scale)要解决的问题。

因为数据是有不同类别的,比如:类目(category),数值,时间(time)等,所以,我们也需要不同的比例尺。D3中提供了丰富的比例尺,具体的可以参考D3文档。

  • 注:《图形语法》书中把数据类型分为:Nominal,Ordinal,Interval,Ratio。但是解释有点难,所以这里就简单的用类目,数值,时间等来分

Scale都有两个概念:

  • domain:就是该维度的真实值的范围
  • range:映射到画布展示上时的坐标范围

概念有点抽象,请看下面实际例子中的应用

Step 4.1 X轴比例尺

X轴上是国家名,是离散的值,并且我们需要画的是柱图,那么我们可以用比例尺:d3.scaleBand, 参考文档:https://github.com/d3/d3-scale/blob/v2.2.2/README.md#scaleBand

  1. let xScale = d3.scaleBand()
  2. .domain(['巴西', '印尼', '美国', '印度', '中国', '世界人口'])
  3. .range([0, width])
  4. .padding(0.1)

解读:

  • domain 中直接写了6个国家的名字,主要是为了看的更清楚,实际中可以通过: meanDataset.map(d=>d.key) 来获得该结果
  • range: 因为我们的SVG的大小是:宽400像素,高300像素,所以X轴是:[0, 400]
  • 额外设置了 10% 的空白间隔(padding),是为了避免柱子都紧挨在一起

我们用如下程序来打印一下各个国家展示在X轴的坐标:

  1. console.log('每根柱子的宽度为:' + xScale.bandwidth())
  2. ['巴西', '印尼', '美国', '印度', '中国', '世界人口'].forEach(
  3. function(country) {
  4. console.log('' + country + ' 的x轴开始位置是: ' + xScale(country));
  5. }
  6. )

得到如下结果:

  1. 每根柱子的宽度为: 59.01639344262295
  2. 巴西 的x轴开始位置是: 6.557377049180332
  3. 印尼 的x轴开始位置是: 72.1311475409836
  4. 美国 的x轴开始位置是: 137.70491803278688
  5. 印度 的x轴开始位置是: 203.27868852459017
  6. 中国 的x轴开始位置是: 268.8524590163934
  7. 世界人口 的x轴开始位置是: 334.4262295081967

Step 4.2 Y轴比例尺

Y轴是各个国家的人口数,是连续的数值类型,我们可以用比例尺:d3.scaleLinear, 参考文档:https://github.com/d3/d3-scale/blob/v2.2.2/README.md#scaleLinear

  1. let yScale = d3.scaleLinear()
  2. .domain([0, d3.max(meanDataset.map(d => d.value))]).nice()
  3. .range([height, 0])

解读:

  • domain中是一个数值范围,因为我们需要从0开始,所以这里是写了 [0, 656018.5]
  • domain后面又额外的调用了 nice(), 主要是因为对于实际场景的中数值,往往其最大值并不是一个比较恰好的整数,如果调用nice做处理后,将会由 [0, 656018.5] 变为:[0, 700000] 会看着更好些
  • range:因为我们要映射到高度为 300像素的SVG中, 这里需要额外注意的是: 范围是: [300, 0], 而不是 [0, 300], 这个主要是SVG的Y轴坐标是从上往下增加的,而我们的柱图的”坐标轴标记“(Axis)却是从下向上的,如果这里设置为:[0, 300], 后面的"yAxis”展示会是反的。

Step 5. 开始画柱图的柱子

做了这么多的准备后,我们终于可以开始画柱子了!

  1. svg.selectAll("rect")
  2. .data(meanDataset)
  3. .join("rect")
  4. .attr("x", d => xScale(d.key))
  5. .attr("y", d => yScale(d.value))
  6. .attr("height", d => height - yScale(d.value))
  7. .attr("width", xScale.bandwidth())
  8. .style("fill", "#3EB9BA")

解读:

1). 这里的 data() 是从图中 SVG 父节点选择出所有的 rect(SVG矩形),注意最开始选择时这个是空但是没关系。data()作用就是把数据中数组的每一行和一个 rect图形进行绑定。

2). join 操作是 D3 的最新版本 (版本5.x) 中引入的新操作,主要是为了简化数据绑定,在旧的版本中,D3的数据绑定有3个概念

  • enter: 新的数据到来,之前没有绑定到图形上
  • update: 新的数据之前已经绑定到图形上
  • exit:之前绑定到图形上的数据, 已经不存在了 (需要图形更新去掉之前存在的)

join操作简化了这些操作(当然,仍然能把 enter, update, exit 三个函数当成参数传给 join (所以,自行了解D3的 enter,update,exit 概念还是很重要的)。但是本例子中,只是用静态数据画图,而不涉及更新等操作,所以,就可以简单的 join('rect')

3). 对于 SVG rect 矩形所具有的属性:x,y,height, width 进行赋值,这里,一方面可以直接赋值数值,另一方面也可以传入一些函数。这些函数的声明类似于:

  1. function(d, i) {
  2. return xScale(some_function(d));
  3. }

这里:

  • 传入的第一个参数是对于需要绑定到当前 rect 的数据行。比如:对于我们的例子,我们是绑定 每个 rect 到 meanDataset 的每行,那么,这里的 d 就是
    {"key":"巴西","value":18764}
  • 传入的第二个参数是当前元素在 meanDataset 的数组中的索引, 第一rect对应的就是 0

4). 默认的颜色是黑色,所以别忘了设置矩形的颜色为好看的“观远”色

获得图形:

4686e9efb06e44fd942ec19fa88f21b6.png

Step 6. 增加坐标轴支持

虽然说D3是相对底层的图形库,但是它提供了大量丰富的图形相关的算法支持,比如:坐标轴(Axis)的支持,其中封装了很多操作,比如:

  • 根据指定的比例尺(Scale),该划分多少个tick (标度)
  • 每个刻度上的显示文本是什么
  • 以及一些常见的坐标轴的可配置项等

可以从 https://github.com/d3/d3-axis/tree/v1.0.12 看起:

  • d3.axisTop - 顶部坐标轴
  • d3.axisRight - 右侧坐标轴
  • d3.axisBottom - 底部坐标轴
  • d3.axisLeft - 左侧坐标轴

简单调用一下,并绑定到之前的X轴和Y轴的比例尺上,

  1. svg.append("g")
  2. .call(d3.axisBottom(xScale))
  3. svg.append("g")
  4. .call(d3.axisLeft(yScale).ticks(null, null))

得到图形:

fef49a4c4be14ce7172c4bc04760b42e.png

我们发现了如下问题:

  1. 对于底部坐标轴,默认展示是y轴上放在了 0 的位置(最上面)
  2. 对于左侧坐标轴,因为刻度tick和文字都是放在左侧的,而我们的坐标轴放在了 X轴为 0 的位置,所以,tick和文字都被隐藏了。

为了解决第一个问题,我们需要使用SVG的 transform 转化, SVG的transform有如下操作:

  • translate:平移
  • scale:放大/缩小
  • rotate:旋转
  • skewX & skewY:倾斜

对于问题1,我们可以使用 translate 平移到底部来解决。(但是如果移到图形的底部,就也会有第2个问题,坐标轴的刻度文字将被隐藏)

为了解决第二个问题,一般有两种解法:

  1. 首先预先定义上下左右的空闲位置大小 (margin),然后在计算各个矩形坐标,坐标轴位置等,考虑到这些margin空白的大小。一般会采用这个方案,但是对于本例子中,就意味着之前的代码很多地方都要改过。
  2. 另一种比较偷懒一点的方法是使用 SVG 的 viewBox,指定看到的坐标范围,并调大画布SVG大小。

这里用第二种方案来演示一下,比如:之前我们的SVG是宽400,高300的图形,对于:

  1. viewBox ( min-x, min-y, width, height )

我们可以使用 (-50, -15, 430, 345) 作为 viewBox,那么之前的原点 (0, 0) 在新的显示下将不在是在屏幕最左上角,而是向“东偏南”方向移动了一点。另外,我们也可以额外增大 SVG 元素的大小为:宽度 (50 + 430),高度(15 + 345), 变为: 480 * 360,以避免图像被缩小。

修改代码如下:

  1. svg = svg
  2. .attr("width", width + 80)
  3. .attr("height", height + 60)
  4. .attr("viewBox", `-50 -15 ${width + 30} ${height + 45}`)
  5. svg.append("g")
  6. .attr("transform", `translate(0,${height})`)
  7. .call(d3.axisBottom(xScale))
  8. svg.append("g")
  9. .call(d3.axisLeft(yScale).ticks(null, null))

获得图形:

ec0fda7d3be35dcf1b3a1fb080e2b2a4.png

Step 7. 扩展颜色支持

单调颜色的世界是不完美的,我们可以加上颜色支持。D3预制了不少颜色模式,可以参考:Color Schemes (d3-scale-chromatic):https://github.com/d3/d3-scale-chromatic/tree/v1.5.0 对于不同类型的数据,有不同的颜色模式。

我们这里用和 Tableau 10 中预制的颜色模式:d3.schemeTableau10,来对每个矩形标色

  1. svg.selectAll("rect")
  2. .data(meanDataset)
  3. .join("rect")
  4. .attr("x", d => xScale(d.key))
  5. .attr("y", d => yScale(d.value))
  6. .attr("height", d => height - yScale(d.value))
  7. .attr("width", xScale.bandwidth())
  8. // .style("fill", "#3EB9BA")
  9. .style("fill", (d, i) => d3.schemeTableau10[i % 10])

修改的只有最后一行,把观远色改为使用 d3.schemeTableau10,注意,因为这个颜色模板只有10种颜色,所以,我们要调用对10的“取模运算”。

有了颜色,颜值马上上升了:

a4d34d70bf53d0924200f189d1e708e8.png

Step 8. 增加文本标签(Label)展示

因为我们学会了使用比例尺,那么加Label变得非常容易,其位置其实和矩形 Rect 是一致的,只是我们稍微向上偏移一点。我们使用SVG的TEXT元素来画Label

  1. svg.append("g")
  2. .attr("font-size", 10)
  3. .selectAll("text")
  4. .data(meanDataset)
  5. .join("text")
  6. .attr("x", d => xScale(d.key) + xScale.bandwidth() / 2)
  7. .attr("y", d => yScale(d.value) - 5)
  8. .attr('text-anchor', 'middle')
  9. .text(d => d3.format(",")(d.value))

这里需要注意:

  • 通过 text-anchor 设置为middle, 并计算x轴位置时,在对每个矩形的坐标x基础上,额外加了半个Rect的宽度,这样就是中间对齐的了!(So Easy)
  • 默认的文本展示为: 656018.5 , 但是我们想要展示为更好看一点的 656,018.5

为了转化数值为更好的展示,我们就需要用D3的number format 库了 (对于时间,也有 time format库),文档在:https://github.com/d3/d3-format/tree/v1.4.4, 通过文档,我们可以看到如下格式可以在千分位加上逗号:

  1. d3.format(",")(d.value)

最终图形如下:

67dde29e4c6ddbab1210f790f27d3f35.png

程序附录

看到这里,估计你也想直接试试D3了,我把完整的程序也列一下,可以把它保存为本地的 .html 文件,然后chrome浏览器打开

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <title>D3 Demo</title>
  6. <script src="https://d3js.org/d3.v5.js"></script>
  7. </head>
  8. <body>
  9. <svg></svg>
  10. </body>
  11. <script>
  12. let dataset = [
  13. {"year": 2011, "country": "巴西", "population": 18203},
  14. {"year": 2012, "country": "巴西", "population": 19325},
  15. {"year": 2011, "country": "印尼", "population": 23489},
  16. {"year": 2012, "country": "印尼", "population": 23438},
  17. {"year": 2011, "country": "美国", "population": 29034},
  18. {"year": 2012, "country": "美国", "population": 31000},
  19. {"year": 2011, "country": "印度", "population": 104970},
  20. {"year": 2012, "country": "印度", "population": 121594},
  21. {"year": 2011, "country": "中国", "population": 131744},
  22. {"year": 2012, "country": "中国", "population": 134141},
  23. {"year": 2011, "country": "世界人口", "population": 630230},
  24. {"year": 2012, "country": "世界人口", "population": 681807}
  25. ]
  26. let meanDataset = d3.nest()
  27. .key(d => d.country)
  28. .rollup(d => d3.mean(d.map(x => x.population)))
  29. .entries(dataset)
  30. let width = 400
  31. let height = 300
  32. let svg = d3.select("svg")
  33. .attr("width", width)
  34. .attr("height", height);
  35. let xScale = d3.scaleBand()
  36. .domain(['巴西', '印尼', '美国', '印度', '中国', '世界人口'])
  37. .range([0, width])
  38. .padding(0.1)
  39. let yScale = d3.scaleLinear()
  40. .domain([0, d3.max(meanDataset.map(d => d.value))]).nice()
  41. .range([height, 0])
  42. svg = svg
  43. .attr("width", width + 80)
  44. .attr("height", height + 60)
  45. .attr("viewBox", `-50 -15 ${width + 30} ${height + 45}`)
  46. svg.selectAll("rect")
  47. .data(meanDataset)
  48. .join("rect")
  49. .attr("x", d => xScale(d.key))
  50. .attr("y", d => yScale(d.value))
  51. .attr("height", d => height - yScale(d.value))
  52. .attr("width", xScale.bandwidth())
  53. // .style("fill", "#3EB9BA")
  54. .style("fill", (d, i) => d3.schemeTableau10[i % 10])
  55. svg.append("g")
  56. .attr("transform", `translate(0,${height})`)
  57. .call(d3.axisBottom(xScale))
  58. svg.append("g")
  59. .call(d3.axisLeft(yScale).ticks(null, null))
  60. svg.append("g")
  61. .attr("font-size", 10)
  62. .selectAll("text")
  63. .data(meanDataset)
  64. .join("text")
  65. .attr("x", d => xScale(d.key) + xScale.bandwidth() / 2)
  66. .attr("y", d => yScale(d.value) - 5)
  67. .attr('text-anchor', 'middle')
  68. .text(d => d3.format(",")(d.value))
  69. </script>
  70. </html>

总结

本文只是D3的起步,主要是简单介绍了一些基本概念:比例尺,数据绑定,SVG操作等,D3还有很多高级功能值得探索,比如:动画,tooltip,拖拽等。

不要被“底层”所吓到,D3虽然看着复杂,但是由于有理论支撑,虽然D3有1000+个不同的API,但是其被很好的组织在了不同的类别和目的下,使用还是相对方便的。正如“底层”的C语言也能实现 Windows/Linux 操作系统,基于D3,也能构造出非常易用的高级图形库,比如:plotly: https://plotly.com/javascript/

另外,我们不能把D3局限在一些常见的统计图形上(比如:柱图,饼图等),而其由于最强大的表达能力,以及丰富的预制图形算法库,可以广泛被用于各种创意图形的绘制,比如分析新冠的致命程度:https://observablehq.com/@yy/covid-19-fatality-rate

1c9b3d848402524138a6f20615116391.png

所以,本文称D3为艺术家是有依据的。

D3很好的回答了WHY的问题,从此我们做可视化也算是有了理论的支持了!

本系列的下一篇:《技术人员眼中的BI之可视化 —— 标准家:Vega & Vega-Lite》将会比本文更轻松一些, Stay Tuned!

注:本文来自于观远数据吴宝琪原创,转载或更多交流请关注WeChat:架构587

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值