JS-D3库
基本地址
<script src="https://d3js.org/d3.v6.min.js"></script>
Github: https://github.com/d3/d3
介绍
D3的主要功能是基于数据来变化DOM文档,比如根据输入的信息来快速生成/变更一个html中的table。或者用同样的数据生成可交互的SVG。D3能够支持HTML,SVG,CSS这些文档的相关操作,D3可能不是非常灵活,但是其速度很快,能够支持大规模数据集,并且能够自定义交互行为和动画。此外,D3通过module的方式允许代码重用。
学习D3最好通过实际例子入手,也可以直接拿这些例子改成自己的数据来可视化,这样能加快进度。
Observable平台
可以在Observable上面学习D3,能够迅速看到代码运行后的结果。
https://observablehq.com/
Observable不是Javascript,Observable允许用户使用D3,Three,Tensorflow等库,但是:
- Observable中,每个cell中的脚本是无关的,一个cell中语法错误,其他的cells还是会运行。
- local variable只有在定义它们的那个cell中才是可见的。
- 比如有一个cell,只包含{var local = 1;}这个local对其他cells就不可见。
- named cell对应的变量只能赋值一次,比如第一个cell foo=2,那么其他Cell就只能读foo这个值,不能写。也就是说,每个named cell可以视作是某个函数的声明,而不是一条赋值语句。
- cells是按照数据互相依赖的拓扑序来运行的,因此可以随便打乱每个cell的顺序。不需要像Js一样从上到下写代码。当修改了一个cell之后,点击运行,只有这个cell依赖的cells的会重跑。
- 由于cells是按照拓扑序来运行的,因此不允许circular definition,即循环定义。
- implicitly await Promise
- 如果一个cells是generator,那么所有引用它的cells都能看到的是最近yielded的值
- 复杂的statements要用{}抱起来,然后return或者yield。
-
123456
{
let
sum = 0;
for
(
let
i = 0; i < 10; ++i) {
sum += i;
}
return
sum;
-
- cell有一个特别的viewof单元,可以让cell可见。
- mutable operator
- 有一个标准库,能够很轻易地完成一些功能
- md`Hello, I’m *Markdown*!`
- 能够从其他notebook中import any named cell
- require是AMD(Asynchronous Module Definition)格式的,而不是CommonJS风格的
- d3 = require("d3@6")
如果一个cell在不断运行,没法释放资源,可以使用invalidation promise(作为页面刷新党,感觉刷新是不是可以有类似功能)
- { invalidation.then(() => console.log("I was invalidated.")); }
Observable+d3:
- 引入数据: values = FileAttachment("values@1.json").json()
- import d3: d3 = require("d3@6")
- 引入其他人的cell: import {chart as chart1} with {values as data} from "@d3/histogram"
- 画图: chart1
- 改变图的高度:
- height = 200
- import {chart as chart2} with {values as data, height} from "@d3/histogram"
- 生成随机数: values3 = Float64Array.from({length: 2000}, d3.randomNormal(mu, 2))
- 规定x轴范围: x = d3.scaleLinear([-10, 10], [margin.left, width - margin.right])
Data
例如,在cell上上传一个附件temperature.csv,然后通过d3来parse
1 | d3.csvParse(await FileAttachment( "temperature.csv" ).text()) |
d3默认不去推测类型,所以从文件中读取得到的都只是字符串的格式,要使其自动推测格式,只需加上d3.autoType选项
1 | d3.csvParse(await FileAttachment( "temperature.csv" ).text(), d3.autoType) |
计算数据的范围:
1 | d3.extent(data, d => d.date) |
从数据中提取子属性
temperatures = data.map(d => d.temperature)
Scales
对于一个如下所示的数据:
1 2 3 4 5 6 7 8 9 10 | fruits = [ {name: "🍊" , count: 21}, {name: "🍇" , count: 13}, {name: "🍏" , count: 8}, {name: "🍌" , count: 5}, {name: "🍐" , count: 3}, {name: "🍋" , count: 2}, {name: "🍎" , count: 1}, {name: "🍉" , count: 1} ] |
我们要将其用如下条形图展示,那么就要把count属性映射到x轴,把name属性映射到y轴
映射函数分别如下:
这里x使用了linear scale,因为count是个标量,而且条形图长度要与count成正比
1 2 3 4 | x = d3.scaleLinear() .domain([0, d3.max(fruits, d => d.count)]) .range([margin.left, width - margin.right]) .interpolate(d3.interpolateRound) |
这里y使用了band scale,因为name对应名词,而且每条bar都要求很粗。
例子中的scale都使用了method chaining的方式来进行设置。
1 2 3 4 5 | y = d3.scaleBand() .domain(fruits.map(d => d.name)) .range([margin.top, height - margin.bottom]) .padding(0.1) .round( true ) |
每个scale都有两个属性: domain和range,这一对属性将domain中的数据映射到range,比如说对于linear scale,domain为[0, 21], range为[margin.left, width - margin.right],那么d3就会把0映射到margin.left处,依次等距类推。
上面的例子中,可以用x.domain()或者x.range()来读取这对属性。而x(21)=640,也即width - margin.right,x(0) = 30,也即margin.left。
这里的margin用来给坐标轴标号等留位置。
文中用了一个有趣的条形图的一部分来向我们展示x的值:
一个按钮用来输入count这个值。
1 2 3 4 5 6 7 8 9 10 11 | viewof count = { const form = html`<form style= "font: 12px var(--sans-serif); display: flex; align-items: center; min-height: 33px;" > <label style= "display: block;" > <input name= "input" type= "range" min= "0" max= "21" value= "12" step= "1" style= "width: 180px;" > count = <output name= "output" ></output> </label> </form>`; form.oninput = () => form.output.value = form.value = form.input.valueAsNumber; form.oninput(); return form; } |
一根bar用来展示具体值:
1 2 3 4 | html`<svg viewBox= "0 0 ${width} 33" style= "max-width: ${width}px; font: 10px sans-serif; display: block;" > <rect fill= "steelblue" x= "${x(0)}" width= "${x(count) - x(0)}" height= "33" ></rect> <text fill= "white" text-anchor= "end" x= "${x(count)}" dx= "-6" dy= "21" >${count}</text> </svg>` |
为了画条形图:
1. 创建一个G element
1 2 3 | <g fill= "steelblue" > ${fruits.map(d => svg`<rect y= "${y(d.name)}" x= "${x(0)}" width= "${x(d.count) - x(0)}" height= "${y.bandwidth()}" ></rect>`)} </g> |
2. 然后把G element传给d3.select。
1 | d3. select (svg`<g transform= "translate(0,${margin.top})" >`) |
3. 使用select.call将axis渲染到G element中
1 2 | d3. select (svg`<g transform= "translate(0,${margin.top})" >`) .call(d3.axisTop(x)) |
4. the domain path for a minimalist style
1 2 3 | d3. select (svg`<g transform= "translate(0,${margin.top})" >`) .call(d3.axisTop(x)) .call(g => g. select ( ".domain" ).remove()) |
5. 使用selection.node来获取渲染后的G element以便嵌入到其他元素中
1 2 3 4 | ${d3. select (svg`<g transform= "translate(0,${margin.top})" >`) .call(d3.axisTop(x)) .call(g => g. select ( ".domain" ).remove()) .node()} |
Scale还能做其他的事,比如将值映射为颜色,例如,下面这个离子将数值映射为蓝色的深浅:
1 2 3 | color = d3.scaleSequential() .domain([0, d3.max(fruits, d => d.count)]) .interpolator(d3.interpolateBlues) |
应用到bar上,这里还考虑到了蓝色深浅标签颜色也要不同:
1 2 3 4 5 6 7 8 9 10 11 12 | html`<svg viewBox= "0 ${margin.top} ${width} ${height - margin.top}" style= "max-width: ${width}px; font: 10px sans-serif;" > <g> ${fruits.map(d => svg`<rect fill= "${color(d.count)}" y= "${y(d.name)}" x= "${x(0)}" width= "${x(d.count) - x(0)}" height= "${y.bandwidth()}" ></rect>`)} </g> <g text-anchor= "end" transform= "translate(-6,${y.bandwidth() / 2})" > ${fruits.map(d => svg`<text fill= "${d3.lab(color(d.count)).l < 60 ? " white " : " black "}" y= "${y(d.name)}" x= "${x(d.count)}" dy= "0.35em" >${d.count}</text>`)} </g> ${d3. select (svg`<g transform= "translate(${margin.left},0)" >`) .call(d3.axisLeft(y)) .call(g => g. select ( ".domain" ).remove()) .node()} </svg>` |
Shapes
D3提供了一些特定的vocabulary,用来产生多种形状。借用这些,用户可以画圆形,矩形,线或者任何其他任何形状。一条path的形状是由SVG Path data language规定的,其底层很类似于pen plotter的语法。例如:
Mx,y 移动到[x,y]这个点
Lx,y 画线到[x,y]这个点
hx在x这里画一条直线
vy在y这里画一条竖线
z关闭当前的subpath
以line chart为例,其具体命令是:
1 2 3 4 5 6 7 | { let path = `M${x(data[0].date)},${y(data[0].close)}`; for ( let i = 1; i < data.length; ++i) { path += `L${x(data[i].date)},${y(data[i].close)}`; } return path; } |
不过d3有更方便的封装函数: d3.line。d3.line会生成一个默认的line generator。调用这个generator的x方法和y方法能够得到对应的坐标。
1 2 3 | line = d3.line() .x(d => x(d.date)) .y(d => y(d.close)) |
接着,将他嵌入到名为path的元素中的d属性中。这里为了避免两个线段连接处的尖峰误导判断,教程中设置stoke-miterlimit=1。
1 2 3 4 5 | html`<svg viewBox= "0 0 ${width} ${height}" > <path d= "${line(data)}" fill= "none" stroke= "steelblue" stroke-width= "1.5" stroke-miterlimit= "1" ></path> ${d3. select (svg`<g>`).call(xAxis).node()} //已经定义好的xAis ${d3. select (svg`<g>`).call(yAxis).node()} </svg>` |
接下来以area为例,area需要定义三个值: x, y0(baseline),y1(topline)。
1 2 3 4 | area = d3.area() .x(d => x(d.date)) .y0(y(0)) .y1(d => y(d.close)) |
1 2 3 4 5 | html`<svg viewBox= "0 0 ${width} ${height}" > <path fill= "steelblue" d= "${area(data)}" ></path> ${d3. select (svg`<g>`).call(xAxis).node()} ${d3. select (svg`<g>`).call(yAxis).node()} </svg>` |
1 2 3 | xAxis = g => g .attr( "transform" , `translate(0,${height - margin.bottom})`) .call(d3.axisBottom(x).ticks(width / 80).tickSizeOuter(0)) |
1 2 3 4 | yAxis = g => g .attr( "transform" , `translate(${margin.left},0)`) .call(d3.axisLeft(y).ticks(height / 40)) .call(g => g.select( ".domain" ).remove()) |
当然也可以修改baseline即y0,使之不为0,画出一个条带。
当然,也可以一次画多条线:
1 2 3 4 5 6 7 8 9 | html`<svg viewBox= "0 0 ${width} ${height}" > <path d= "${areaBand(data)}" fill= "#ddd" ></path> <g fill= "none" stroke-width= "1.5" stroke-miterlimit= "1" > <path d= "${lineMiddle(data)}" stroke= "#00f" ></path> <path d= "${line(data)}" stroke= "#000" ></path> </g> ${d3. select (svg`<g>`).call(xAxis).node()} ${d3. select (svg`<g>`).call(yAxis).node()} </svg>` |
1 2 3 4 5 6 7 8 9 | html`<svg viewBox= "0 0 ${width} ${height}" > <path d= "${areaBand(data)}" fill= "#ddd" ></path> <g fill= "none" stroke-width= "1.5" stroke-miterlimit= "1" > <path d= "${areaBand.lineY0()(data)}" stroke= "#00f" ></path> <path d= "${areaBand.lineY1()(data)}" stroke= "#f00" ></path> </g> ${d3.select(svg`<g>`).call(xAxis).node()} ${d3.select(svg`<g>`).call(yAxis).node()} </svg>` |
另一个非常基础的形状是arc,arc有四个属性需要定义: innerRadius,outerRadius, startangle, endAngle。
下面的arc接受一对参数[startAngle, endAngle],将前者设置为startAngle,后者设置为endAngle。
1 2 3 4 5 | arc = d3.arc() .innerRadius(210) .outerRadius(310) .startAngle(([startAngle, endAngle]) => startAngle) .endAngle(([startAngle, endAngle]) => endAngle) |
1 2 3 | html`<svg viewBox= "-320 -320 640 640" style= "max-width: 640px;" > ${Array.from({length: n}, (_, i) => svg`<path stroke= "black" fill= "${d3.interpolateRainbow(i / n)}" d= "${arc([i / n * 2 * Math.PI, (i + 1) / n * 2 * Math.PI])}" ></path>`)} </svg>` |
d3还定义了一个简单的自动计算arc角度的pie图函数:d3.pie。
1 2 3 | pieArcData = d3.pie() .value(d => d.count) (fruits) |
1 2 3 4 5 6 | arcPie = d3.arc() .innerRadius(210) .outerRadius(310) .padRadius(300) .padAngle(2 / 300) .cornerRadius(8) |
1 2 3 4 5 6 7 8 9 | html`<svg viewBox= "-320 -320 640 640" style= "max-width: 640px;" text-anchor= "middle" font-family= "sans-serif" > ${pieArcData.map(d => svg` <path fill= "steelblue" d= "${arcPie(d)}" ></path> <text fill= "white" transform= "translate(${arcPie.centroid(d).join(" , ")})" > <tspan x= "0" font-size= "24" >${d.data.name}</tspan> <tspan x= "0" font-size= "12" dy= "1.3em" >${d.value.toLocaleString( "en" )}</tspan> </text> `)} </svg>` |
Animation
动画可以被认为是在隔一段时间向用户展示一张图片。也就是说,我们可以认为动画是图片的序列,这个序列中的每个元素都是一个参数为时间t的函数。
如下面的例子,stroke-dasharray属性定义的图形将具有动画效果(其他图形保持不变)。这里,连续动画一般是通过离散的关键帧定义的,其他中间帧则是通过插值(或者tweening)生成的。stroke-dasharray本身可以生成虚线,它的第一个参数是实线段的长度即可见的长度,第二个参数是虚线段的长度即不可见的长度,通过调整这两个属性,我们就能够实现动画效果。
1 2 3 4 5 | replay, html`<svg viewBox= "0 0 ${width} ${height}" > ${d3.select(svg`<path d= "${line(data)}" fill= "none" stroke= "steelblue" stroke-width= "1.5" stroke-miterlimit= "1" stroke-dasharray= "0,1" ></path>`).call(reveal).node()} ${d3.select(svg`<g>`).call(xAxis).node()} ${d3.select(svg`<g>`).call(yAxis).node()} </svg>` |
这里我们使用了d3.interpolate进行插值。
1 2 3 4 5 6 7 | reveal = path => path.transition() .duration(5000) .ease(d3.easeLinear) .attrTween( "stroke-dasharray" , function () { const length = this .getTotalLength(); return d3.interpolate(`0,${length}`, `${length},${length}`); }) |
interpolate也能应用在许多其他方面。
当定义一个transition的时候,可以通过transition.attrTween来调用已经声明好的interpolator,也可以使用transition.attr或者transition.style来让d3决定具体interpolator。
此外,还能通过Observable的数据流来画图,每当t更改时,就重新创建图形,但是这样的效率更低。
Joins
d3 selection帮助用户快速增量型地动态更新图。d3并不是要求用户直接定义最终DOM的状态,而是要求用户定义将当前状态转化为期望的状态所需要的更改,比如插入,更新或者删除。
例如,以下代码展示了26个字母。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | chart1 = { const svg = d3.create( "svg" ) .attr( "viewBox" , [0, 0, width, 33]) .attr( "font-family" , "sans-serif" ) .attr( "font-size" , 10) .style( "display" , "block" ); svg.selectAll( "text" ) .data(alphabet) .join( "text" ) .attr( "x" , (d, i) => i * 17) .attr( "y" , 17) .attr( "dy" , "0.35em" ) .text(d => d); return svg.node(); } |
如果想要动态改变这个SVG,最好就使用d3.select把需要更新的元素单独拿出来更新:
1 2 3 4 5 6 7 8 | randomLetters = { while ( true ) { yield d3.shuffle(alphabet.slice()) .slice(Math.floor(Math.random() * 10) + 5) .sort(d3.ascending); await Promises.delay(3000); } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | chart2 = { const svg = d3.create( "svg" ) .attr( "viewBox" , [0, 0, width, 33]) .attr( "font-family" , "sans-serif" ) .attr( "font-size" , 10) .style( "display" , "block" ); let text = svg.selectAll( "text" ); return Object.assign(svg.node(), { update(letters) { text = text .data(letters) .join( "text" ) .attr( "x" , (d, i) => i * 17) .attr( "y" , 17) .attr( "dy" , "0.35em" ) .text(d => d); } }); } |
1 | chart2.update(randomLetters) |
在调用selection.data时,d3会计算三个集合enter, update和exit,针对这三个集合中的数据,能够定义不同的行为。这里使用selection.join,自动将enter部分和update部分合在一起并排序返回,由接下来的函数决定如何绘制。
当然,update对应的元素本身不需要重新绘制了,所以上面的函数还可以进一步化简:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | chart3 = { const svg = d3.create( "svg" ) .attr( "viewBox" , [0, 0, width, 33]) .attr( "font-family" , "sans-serif" ) .attr( "font-size" , 10) .style( "display" , "block" ); let text = svg.selectAll( "text" ); return Object.assign(svg.node(), { update(letters) { text = text .data(letters, d => d) .join( enter => enter.append( "text" ) .attr( "y" , 17) .attr( "dy" , "0.35em" ) .text(d => d), update => update, exit => exit.remove() ) .attr( "x" , (d, i) => i * 17); } }); } |
此外,还可以进一步为enter和exit加上动画:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | chart4 = { const svg = d3.create( "svg" ) .attr( "viewBox" , [0, 0, width, 33]) .attr( "font-family" , "sans-serif" ) .attr( "font-size" , 10) .style( "display" , "block" ); let text = svg.selectAll( "text" ); return Object.assign(svg.node(), { update(letters) { const t = svg.transition().duration(750); text = text .data(letters, d => d) .join( enter => enter.append( "text" ) .attr( "y" , -7) .attr( "dy" , "0.35em" ) .attr( "x" , (d, i) => i * 17) .text(d => d), update => update, exit => exit .call(text => text.transition(t).remove() .attr( "y" , 41)) ) .call(text => text.transition(t) .attr( "y" , 17) .attr( "x" , (d, i) => i * 17)); } }); } |
交互
Ben Shneiderman关于交互的格言如下:
1. 首先提供概述
2. 缩放+过滤
3. details on demand
第一个例子通过d3.pairs允许用户将鼠标移到某个位置停下的时候看到具体信息。
1 2 3 4 5 6 7 8 9 10 11 | html`<svg viewBox= "0 0 ${width} ${height}" > <path d= "${line(data)}" fill= "none" stroke= "steelblue" stroke-width= "1.5" stroke-miterlimit= "1" ></path> <g fill= "none" pointer-events= "all" > ${d3.pairs(data, (d, b) => svg`<rect x= "${x(d.date)}" height= "${height}" width= "${x(b.date) - x(d.date)}" > <title>${formatDate(d.date)} ${formatClose(d.close)}</title> </rect>`)} </g> ${d3.select(svg`<g>`).call(xAxis).node()} ${d3.select(svg`<g>`).call(yAxis).node()} </svg>` |
还可以使用voroni overlay来展示最近的数据点的tooltip。
但这样非常慢,因此可以自定义一个tooltip,再通过d3.pair改变这个tooltip的属性。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | { const tooltip = new Tooltip(); return html`<svg viewBox= "0 0 ${width} ${height}" > <path d= "${line(data)}" fill= "none" stroke= "steelblue" stroke-width= "1.5" stroke-miterlimit= "1" ></path> ${d3.select(svg`<g>`).call(xAxis).node()} ${d3.select(svg`<g>`).call(yAxis).node()} <g fill= "none" pointer-events= "all" > ${d3.pairs(data, (a, b) => Object.assign(svg`<rect x= "${x(a.date)}" height= "${height}" width= "${x(b.date) - x(a.date)}" ></rect>`, { onmouseover: () => tooltip.show(a), onmouseout: () => tooltip.hide() }))} </g> ${tooltip.node} </svg>`; } |
注意这里因为SVG不支持z-order,所以一定要最后来画tooltip
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | class Tooltip { constructor() { this ._date = svg`<text y= "-22" ></text>`; this ._close = svg`<text y= "-12" ></text>`; this .node = svg`<g pointer-events= "none" display= "none" font-family= "sans-serif" font-size= "10" text-anchor= "middle" > <rect x= "-27" width= "54" y= "-30" height= "20" fill= "white" ></rect> ${ this ._date} ${ this ._close} <circle r= "2.5" ></circle> </g>`; } show(d) { this .node.removeAttribute( "display" ); this .node.setAttribute( "transform" , `translate(${x(d.date)},${y(d.close)})`); this ._date.textContent = formatDate(d.date); this ._close.textContent = formatClose(d.close); } hide() { this .node.setAttribute( "display" , "none" ); } } |
这里再提供了一种d3 style来更改属性。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | { const tooltip = new Tooltip(); const svg = d3.create( "svg" ) .attr( "viewBox" , [0, 0, width, height]); svg.append( "path" ) .attr( "fill" , "none" ) .attr( "stroke" , "steelblue" ) .attr( "stroke-width" , 1.5) .attr( "stroke-miterlimit" , 1) .attr( "d" , line(data)); svg.append( "g" ) .call(xAxis); svg.append( "g" ) .call(yAxis); svg.append( "g" ) .attr( "fill" , "none" ) .attr( "pointer-events" , "all" ) .selectAll( "rect" ) .data(d3.pairs(data)) .join( "rect" ) .attr( "x" , ([a, b]) => x(a.date)) .attr( "height" , height) .attr( "width" , ([a, b]) => x(b.date) - x(a.date)) .on( "mouseover" , (event, [a]) => tooltip.show(a)) .on( "mouseout" , () => tooltip.hide()); svg.append(() => tooltip.node); return svg.node(); } |
这样做还是昂贵的,因为需要为每个可以鼠标悬停的地方分别建立一个元素来监视。所以,我们也可以不建立这些元素,而是反向推算对应的数据。
1 2 3 4 5 6 7 8 9 10 11 12 | { const tooltip = new Tooltip(); return Object.assign(html`<svg viewBox= "0 0 ${width} ${height}" > <path d= "${line(data)}" fill= "none" stroke= "steelblue" stroke-width= "1.5" stroke-miterlimit= "1" ></path> ${d3.select(svg`<g>`).call(xAxis).node()} ${d3.select(svg`<g>`).call(yAxis).node()} ${tooltip.node} </svg>`, { onmousemove: event => tooltip.show(bisect(data, x.invert(event.offsetX))), onmouseleave: () => tooltip.hide() }); } |
这里使用二分方法检查对应的数据。
bisect = { const bisectDate = d3.bisector(d => d.date).left; return (data, date) => { const i = bisectDate(data, date, 1); const a = data[i - 1], b = data[i]; return date - a.date > b.date - date ? b : a; }; }
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 一个奇形怪状的面试题:Bean中的CHM要不要加volatile?
· [.NET]调用本地 Deepseek 模型
· 一个费力不讨好的项目,让我损失了近一半的绩效!
· .NET Core 托管堆内存泄露/CPU异常的常见思路
· PostgreSQL 和 SQL Server 在统计信息维护中的关键差异
· DeepSeek “源神”启动!「GitHub 热点速览」
· 我与微信审核的“相爱相杀”看个人小程序副业
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· 如何使用 Uni-app 实现视频聊天(源码,支持安卓、iOS)
· C# 集成 DeepSeek 模型实现 AI 私有化(本地部署与 API 调用教程)