尧图精选

数据可视化库 Observable Plot 源码深度解析——8 Mark 如何变成 SVG

🕒 发布时间:2026/9/25 18:05:39 📁 来源:尧图网络
第 8 章 Mark 如何变成 SVG本章导读第 5 章数据可视化库 Observable Plot 的步骤 19 只有三行有效代码却完成了数据 → 图形的最后一跳constnodemark.render(index,scales,values,superdimensions,context);if(nodenull)continue;svg.appendChild(node);本章要打开mark.render这个黑盒。选择的样本是 Plot 里最常用、也最有代表性的实现——src/marks/dot.js的Dot.render只有 60 多行却完整展示了创建节点、绑定数据、注入样式、设置属性的全套套路。读完本章你应该能回答render的五个参数分别是什么其中哪一个参数代表数据答案会出乎意料create(svg:g, context)里的context到底提供了什么为什么 Plot 不直接用全局documentPlot 的样式系统为什么要把属性分成挂在g上和挂在子元素上两层这两层分别由style.js的哪个函数负责8.1 渲染契约一个函数五个参数一个返回值所有 Mark 的渲染都必须符合同一个签名。以Dot.render为例// src/marks/dot.js L73-L81节选render(index,scales,channels,dimensions,context){const{x,y}scales;const{x:X,y:Y,r:R,rotate:A,symbol:S}channels;const{r,rotate,symbol}this;const[cx,cy]applyFrameAnchor(this,dimensions);constcirclesymbolsymbolCircle;constsizeR?undefined:r*r*Math.PI;if(negative(r))index[];returncreate(svg:g,context)// ...}五个参数的分工如下——注意第二个参数很容易被误认为是数据参数类型含义典型误用indexnumber[] | null当前 facet 的索引数组才是哪些数据要画当成数据对象数组去读index[i].value错scales{x, y, fx, fy, …}比例尺函数已实例化可调用也带domain()/range()/bandwidth()又拿scales.x去推断 domain多余已算好channels{x?: number[], y?: number[], r?: number[], …}已经缩放过的值按通道名索引误以为需要自己scale(channel.value)会双重缩放dimensions{width, height, marginTop, …}画布尺寸与边距忘记 y 轴已经翻转又反向一次context{document, clip, projection, path, …}渲染环境依赖注入直接使用全局document破坏可测试性关于scales与channels的区别一句话记忆scales是尺子函数channels是量好的长度数组。Dot.render里真正用到的scales只有x/y两个——而且只用来判断这个通道是不是按比例尺算出来的X ? (i) X[i] : cx因为channels里的值已经是像素了。8.2 为什么数据是一个索引数组index是 Plot 渲染设计里最容易被低估的一环。它是一个纯数字数组例如[0, 3, 4, 7]含义是当前 facet 内需要绘制的记录在整份 data 中的下标。这样设计带来三个好处facet 过滤零拷贝第 5 章步骤 19 提到mark.filter(index, channels, values)会按值的有效性筛掉下标。筛掉一个点只是从数组里去掉一个数字不需要复制任何数据对象。数据可以不是数组data可以是Float64Array、可以是{get(i){...}}这类自定义结构甚至可以是函数生成的惰性数据。只要能用下标取到值渲染层就不用关心它的具体形态。节点池pool成为可能因为渲染只依赖下标d3的data(index)绑定可以安全地复用节点pool选项而不必担心数据对象引用变化导致节点重建。代价是渲染代码里到处都是(i) X[i]这种间接访问。Dot.render里那段属性设置就非常典型// src/marks/dot.js L93-L98(selection){selection.attr(cx,X?(i)X[i]:cx).attr(cy,Y?(i)Y[i]:cy).attr(r,R?(i)R[i]:r);}注意这里的双形态写法X存在时传函数每个点不同否则直接传常量所有点相同。这既是最简写法也是一条性能约定——d3 对常量属性能省掉一次函数调用。当你有 10 万个点时这个差别是可测量的。8.3 逐行拆解Dot.render把Dot.render完整读一遍去掉分支细节后// src/marks/dot.js L73-L138精简render(index,scales,channels,dimensions,context){const{x,y}scales;const{x:X,y:Y,r:R,rotate:A,symbol:S}channels;const{r,rotate,symbol}this;const[cx,cy]applyFrameAnchor(this,dimensions);constcirclesymbolsymbolCircle;constsizeR?undefined:r*r*Math.PI;if(negative(r))index[];returncreate(svg:g,context).call(applyIndirectStyles,this,dimensions,context).call(applyTransform,this,{x:Xx,y:Yy}).call((g)g.selectAll().data(index).enter().append(circle?circle:path).call(applyDirectStyles,this).call(/* …按 circle / path 分别设置属性… */).call(applyChannelStyles,this,channels)).node();}可以清晰地切成5 个阶段阶段代码做了什么① 准备const {x, y} scales; …applyFrameAnchor取出需要的通道与常量算出 frameAnchor无 x/y 时的默认位置② 建容器create(svg:g, context)创建一个g作为一层的根所有点都放进去③ 组级样式applyIndirectStyles把 mark 级的常量样式class / fill / stroke / stroke-width / clip…挂到g上④ 位移applyTransform处理dx/dy并在离散比例尺下自动加半格使图元居中⑤ 数据绑定与属性selectAll().data(index).enter().append(...)每个下标创建一个节点再依次注入 direct / channel 样式与几何属性其中第 ④ 步的自动加半格值得单独看// src/style.js L426-L432exportfunctionapplyTransform(selection,mark,{x,y},txoffset,tyoffset){txmark.dx;tymark.dy;if(x?.bandwidth)txx.bandwidth()/2;if(y?.bandwidth)tyy.bandwidth()/2;if(tx||ty)selection.attr(transform,translate(${tx},${ty}));}x.bandwidth()是 band/point 比例尺特有的方法返回一个类别占的像素宽度。当 x 是离散比例尺时scales.x(value)返回的是该 band 的左边界因此必须再平移半个带宽才是中心。这一行解释了很多人遇到的困惑“为什么Plot.dot的点在类别轴上会自动居中而我自己算scales.x时却偏左半格”——因为这半格是applyTransform帮你加的属于渲染层的职责不是比例尺的职责。第 ⑤ 步里有一处防负值的细节// src/marks/dot.js L80if(negative(r))index[];negative(r)检查的是r这个常量this.r如果用户写了r: -5直接把index清空——整个 mark 什么都不画而不是画出 640 个半径为负的圆。这类入口处的显式拒绝是 Plot 避免产生畸形 SVG 的常见做法。8.4 样式系统的三层分工Dot.render里出现了三个apply*Styles加上几何属性构成了 Plot 的样式体系。理解这套分工是写自定义 Mark 时最关键的一步函数挂在哪处理什么典型属性是否 per-elementapplyIndirectStylesg组mark 的常量样式class、fill、stroke、stroke-width、fill-opacity、stroke-dasharray、clip否作用于整组靠继承生效applyChannelStyles子元素通道驱动的动态样式fill、stroke、stroke-width、opacity、fill-opacity、stroke-opacity、aria-label、title、href是每个元素不同applyDirectStyles子元素mark 的元素级常量样式mix-blend-modestyle、opacityattr否但必须挂在元素上// src/style.js L380-L403精简// Note: may mutate selection.node!注意可能会改动 selection.nodeexportfunctionapplyIndirectStyles(selection,mark,dimensions,context){applyClip(selection,mark,dimensions,context);applyAttr(selection,class,mark.className);applyAttr(selection,fill,mark.fill);applyAttr(selection,fill-opacity,mark.fillOpacity);applyAttr(selection,stroke,mark.stroke);applyAttr(selection,stroke-width,mark.strokeWidth);// …stroke-linejoin / linecap / miterlimit / dasharray / dashoffset 等}exportfunctionapplyDirectStyles(selection,mark){applyStyle(selection,mix-blend-mode,mark.mixBlendMode);applyAttr(selection,opacity,mark.opacity);}PS这行 Note 的“元凶”是块内首行的applyClipstyle.js L314-L326当clip: frame时源码会把g再包一层不带 transform 的父g并直接改写selection.node原注释To simplify the adoption of this fix, mutate the passed-in selection.node to return the parent G element。自定义 Mark 若在调用applyIndirectStyles前后缓存了selection.node()的引用要当心这一副作用。为什么applyDirectStyles只处理两个属性因为它必须挂在每个元素上opacity不是继承属性而其余样式都能通过g继承——把可继承的样式放在组上、只把不可继承的放在元素上能把 N 次属性写入降为 1 次。这是 SVG 性能优化的常识Plot 把它固化成了分层约定。而通道样式的实现则是一长串有则写// src/style.js L189-L205节选exportfunctionapplyChannelStyles(selection,{target,tip},{ariaLabel:AL,title:T,fill:F,fillOpacity:FO,stroke:S,strokeOpacity:SO,strokeWidth:SW,opacity:O,href:H}){if(AL)applyAttr(selection,aria-label,(i)AL[i]);if(F)applyAttr(selection,fill,(i)F[i]);// …title / fill-opacity / stroke / stroke-opacity / stroke-width / opacity / href}注意if (F)这种短路判断只有用户真的声明了fill通道才会执行selection.attr(fill, ...)。否则元素就继承g上的fill。这就是 8.3 节里那个组级样式的真正价值——它是继承体系里的默认值来源。回顾Dot的默认样式// src/marks/dot.js L18-L23constdefaults{ariaLabel:dot,fill:none,stroke:currentColor,strokeWidth:1.5};fill: none与stroke: currentColor会被挂到g上于是所有圆点默认是空心、描边跟随文字颜色。而只要你写了fill: categoryapplyChannelStyles就会在每个circle上写入具体的fill从而覆盖继承值——默认值 通道覆盖的优先级链完全由 DOM 继承机制实现Plot 不需要任何样式合并算法。8.5create()11 行的依赖注入create(svg:g, context)是整个渲染层唯一创建 DOM 的入口实现在context.js里全文只有 11 行// src/context.jsimport{creator,select}fromd3;import{maybeClip}from./options.js;exportfunctioncreateContext(options{}){const{documenttypeofwindow!undefined?window.document:undefined,clip}options;return{document,clip:maybeClip(clip)};}exportfunctioncreate(name,{document}){returnselect(creator(name).call(document.documentElement));}短短几行回答了三个设计问题context只是一个环境包{document, clip}其余能力ownerSVGElement、projection、path、filterFacets、getMarkState…都由plot.js在步骤 13 挂上去。核心渲染代码只依赖这个接口不认识浏览器全局对象。document可被替换createContext({document: jsdomDocument})就能在 Node 里渲染。这不是理论可能性——Plot 的测试就是这么跑的。creator(name).call(document.documentElement)是 d3 命名空间的技巧d3.creator(svg:g)返回一个函数.call(documentElement)让它以正确的命名空间创建元素。它必须用call而不是直接调用因为命名空间解析依赖this即所在文档。自定义 Mark 时必须通过create(name, context)创建节点而不是document.createElement。原因不是规范而是实际后果直接创建的元素不会带 SVG 命名空间在某些渲染路径如d3.select(...).attr(xlink:href)下会静默失败。8.6 三种符号绘制路径Dot支持symbol圆、方块、三角…。它的渲染在圆与路径之间分流并且对R半径通道与S符号通道的四种组合分别处理// src/marks/dot.js L99-L133精简(selection){selection.attr(transform,templatetranslate(${X?(i)X[i]:cx},${Y?(i)Y[i]:cy})${A?(i)rotate(${A[i]}):rotate?rotate(${rotate}):}).attr(d,RS?(i){constppath();S[i].draw(p,R[i]*R[i]*Math.PI);returnp;}:R?(i){constppath();symbol.draw(p,R[i]*R[i]*Math.PI);returnp;}:S?(i){constppath();S[i].draw(p,size);returnp;}:((){constppath();symbol.draw(p,size);returnp;})());}三条结论circle是快路径当symbol symbolCircle时直接append(circle)并设cx/cy/r。这是唯一能吃到浏览器原生圆形优化的路径。其它符号走pathd用 d3 的pathRound构建路径字符串。注意d属性是每个元素计算一次的——所以当S与R都不存在时最后一个分支用了一个立即执行的箭头函数(() ...)()只算一次字符串所有元素共用。这个常量提前求值是 60 行代码里最精妙的性能细节。面积而非半径R[i] * R[i] * Math.PI与r * r * Math.PI都是面积。因为 d3 的symbol.draw(context, area)接收的参数是面积而不是半径——这也呼应了第 6 章r走sqrt比例尺的设计数值 → 半径必须开方才能保证数值 → 面积是线性的。另外那个template标签函数也值得一提。它来自src/template.js作用是把含变量的模板字符串编译成惰性函数templatetranslate(${X?(i)X[i]:cx},${Y?(i)Y[i]:cy})${...}只有在真正需要transform时才会拼接字符串避免了先拼一个字符串再发现用不上的开销。8.7 常见误区「render的第一个参数是数据」——不是是索引数组。这是自定义 Mark 时最常见的错误来源。想拿数据请用this.data在initialize里已被物化或通过channels里的数组。「channels里是原始值需要自己缩放」——render拿到的channels是mark.scale()的产物第 5 章步骤 17已经是像素/颜色。再调一次scales.x(value)会双重缩放。「样式可以直接写在子元素上」——可以但你应该先用applyIndirectStyles把可继承的常量挂到g上否则每个元素都要写一遍属性写入次数从 1 变成 N。「必须用document.createElement」——应该用create(name, context)。前者在 SVG 命名空间与可测试性上都会出问题。「render返回null是错误处理」——它是合法的本次不绘制信号。plot.js步骤 19 里有if (node null) continue;所以在数据为空、index.length 0的情况下很多 mark 会返回null而不是空g这能避免留下大量空节点。「d3.select(...).data(index)会绑定数据对象」——它绑定的是数字。所以selection.attr(cx, (i) X[i])里的i是数据下标不是数据。想访问数据要用i反查。8.8 本章小结渲染契约是render(index, scales, channels, dimensions, context) → Node | null数据以索引数组的形式传入channels里的值已经是像素。Dot.render的 5 个阶段准备 →create(svg:g, context)→applyIndirectStyles→applyTransform→data(index).append(...)applyDirectStylesapplyChannelStyles。样式三层分工组级常量继承 元素级常量不可继承属性 通道级动态优先级靠 DOM 继承与属性覆盖自然实现。applyTransform会为 band/point 比例尺自动加半个bandwidth()这是离散轴上图元自动居中的真正原因。context只提供{document, clip}是为了让渲染与浏览器全局解耦可 jsdom 测试一切 DOM 创建都要走create(name, context)。性能要点常量 vs 函数的双形态属性、d字符串的提前求值、symbol.draw(p, area)的面积语义。到这里单张图怎么画出来已经讲完。但从第 5 章我们知道plot()还要处理分面、坐标轴、图例这三件跨越多个 mark的事——它们分别由facet.js、隐式 axis mark、legends.js负责。下一章进入这三块。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →