ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

数据可视化库 Observable Plot 源码深度解析——4 核心抽象逐项学习

数据可视化库 Observable Plot 源码深度解析——4 核心抽象逐项学习 第 4 章 数据可视化库 Observable Plot 核心抽象逐项学习本章导读第 3 章回答有哪些模块本章回答这些模块内部长什么样。Mark/Channel/Scale/Options/Context/Dimensions是 Plot 语义内核的六个面前四个决定数据怎么被理解后两个决定理解之后画到哪、画多大。每节采用同一节奏先给精简源码保留源码原意注释不删再用特点 → 启发 → 思考三层解读。特点说它是这样设计的启发说从中学到什么思考说它为此付出了什么代价。4.1 Mark 基类src/mark.jsMark是所有 30 个标记的父类。理解 Mark 就理解了 Plot 一半。// src/mark.js L10-L135核心精简exportclassMark{constructor(data,channels{},options{},defaults){// 1) 保存基础选项this.datadata;this.facetkeyword(facettrue?include:facet,...);this.fx...;this.fy...;this.facetAnchormaybeFacetAnchor(facetAnchor);// 2) 合并通道用户传入 extraChannels defaultsstyleschannels{...styles(this,options,defaults),...channels};// 3) 过滤掉 value 为 null 且非 optional 的 channelthis.channelsObject.fromEntries(...filternull...);// 4) 保存其他常用字段this.dxdx;this.dydy;this.marginTopmarginTop;...;this.tipmaybeTip(tip);}// 生命周期方法子类通常 override renderinitialize(facets,facetChannels,plotOptions){letdatadataify(this.data);// 1) 跑 transform来自 Plot.options 或 transforms/basicif(this.transform!null)({facets,data}this.transform(data,facets,plotOptions));// 2) 把每个 channel 的 value 转成真实数组constchannelscreateChannels(this.channels,data);// 3) 如果有 sort 选项计算排序后的 domainif(this.sort!null)channelDomain(data,facets,channels,facetChannels,this.sort);return{data,facets,channels};}filter(index,channels,values){// 默认过滤掉 undefined / NaN来自 defined.jsfor(constnameinchannels){const{filterdefined}channels[name];if(filter!null)indexindex.filter((i)filter(value[i]));}returnindex;}scale(channels,scales,context){// 把每个 channel.value 用 scale 转成像素/颜色值constvaluesvalueObject(channels,scales);// 如果是地图投影应用 d3-geoif(context.projection)this.project(channels,values,context);returnvalues;}}子类典型如src/marks/dot.js// src/marks/dot.js L29-L46exportclassDotextendsMark{constructor(data,options{}){const{x,y,r,rotate,symbolsymbolCircle,frameAnchor}options;super(data,{x:{value:x,scale:x,optional:true},y:{value:y,scale:y,optional:true},r:{value:vr,scale:r,filter:positive,optional:true},rotate:{value:vrotate,optional:true},symbol:{value:vsymbol,scale:auto,optional:true}},withDefaultSort(options),defaults);}render(index,scales,channels,dimensions,context){...}}Dot 自身只声明了5 个 channelx/y/r/rotate/symbol并指定了 scale 名。其余样式fill/stroke/strokeWidth/…从defaults经styles()src/style.js合并进来。特点模板方法模式基类把渲染拆成initialize → filter → scale → render四步骨架子类通常只 override 最后一步render像Dot这样甚至只声明通道就够了。骨架在上、变化在下是整章反复出现的组织方式。构造期只做归一化不做计算new Mark(...)阶段只合并通道、过滤空通道、规整字段完全不碰数据内容。所以构造一个 mark是纯声明、无副作用、可重复的能在plot()之前自由组合如Plot.stackY(Plot.bar(...))。语义与外观分离Dot只声明几何通道x/y/r/rotate/symbolfill/stroke 等外观走defaultsstyles()合并。位置是每个 mark 独有的知识外观是同类 mark 共享的默认值两者不该挤在同一张声明表里。默认值靠参数注入defaults是第 4 个构造参数而不是硬编码常量于是不同 mark 能复用同一套基类实现又能各自定制默认外观。启发想写自己的图表库不必一上来就写 30 种图。先定生命周期协议 通道协议再把具体图形压缩成声明 最后一步渲染就能用小得多的代码量覆盖大量图形。this.transform是挂在实例上的可插拔钩子——同一个Bar可以被不同 transform 包裹。组合优于继承把可变部分做成包装函数而不是子类。filter的默认实现是defined过滤 undefined / NaN体现默认正确原则缺数据时悄悄少画一个点而不是画出一个畸形图形。思考Mark同时承担了数据契约initialize / filter / scale与渲染契约render职责偏重。更纯粹的做法是把数据准备抽成独立的 pipeline 对象Plot 选择务实——少一层抽象读起来更直接。this.dx dx这类隐式转换字符串 → 数字在 JS 库源码里很常见但对读者不友好也丢掉了非法输入的错误提示。这是库代码追求宽容的代价。channels {...styles(...), ...channels}的顺序意味着用户传入是整体覆盖默认样式而非叠加。正确但不够灵活用户无法表达在默认 strokeWidth 基础上再加 1。4.2 Channel 通道src/channel.jsChannel 是 Mark 与 Scale 的桥梁。每个 channel 描述这个数据列要怎么映射。语义上 Channel 是 Mark options 的一个子集详见第 2 章 2.2本节按源码模块维度讲src/channel.js的实现。// src/channel.js L8-L18exportfunctioncreateChannel(data,{scale,type,value,filter,hint,labellabelof(value)},name){if(hintundefinedtypeofvalue?.transformfunction)hintvalue.hint;returninferChannelScale(name,{scale,type,value:valueof(data,value),// 把 weight 字段名解析成 data 中对应的值数组label,filter,hint});}valueof(data, value)是 Plot 的核心DSL 解析器——它接受 5 种形式Plot.dot(data,{x:dd.weight,// ① 函数y:height,// ② 字段名字符串fill:[red,blue],// ③ 数组stroke:{value:...},// ④ 完整 channel 选项对象r:5// ⑤ 常量});自动 scale 推断在inferChannelScaleL43// src/channel.js L43-L78exportfunctioninferChannelScale(name,channel){const{scale,value}channel;if(scaletrue||scaleauto){switch(name){casefill:casestroke:casecolor:channel.scaleisEvery(value,isColor)?null:color;break;casefillOpacity:casestrokeOpacity:caseopacity:channel.scaleisEvery(value,isOpacity)?null:opacity;break;casesymbol:channel.scaleisEvery(value,isSymbol)?null:symbol;channel.valuemap(value,maybeSymbol);// plus → symbolPlusbreak;default:channel.scaleregistry.has(name)?name:null;// x/y/r 走这里}}elseif(scalefalse){channel.scalenull;}elseif(scale!null!registry.has(scale)){thrownewError(unknown scale:${scale});}}特点职责单一createChannel只做两件事——把用户输入翻译成值数组交给valueof、把 scale 名推断成具体类型交给inferChannelScale。取数、推断、调用被拆成三个函数。靠值的内容反推 scale 类型isEvery(value, isColor)检查的是每个值本身是不是已经是颜色而不是用户有没有声明颜色通道。这正是 Plot 零配置卖点的实现位置。label labelof(value)写在默认参数里从字段名自动生成轴标题 / 图例名用户显式传 label 时才覆盖——默认值设计得很聪明。hint是一条轻量类型通道当 value 是带.hint的变换对象时把类型提示透传给 scale例如让 time scale 知道该按时间处理是一个几乎零成本的类型系统。启发约定优于配置落地成代码的具体形态就是把推断逻辑集中到一个函数里、所有 Mark 共享。与其在每个 mark 里写if (typeof x ...)不如收敛为inferChannelScale。isEvery(value, isColor)这招很值得抄如果数据自身已经自洽全是合法颜色就不要再套一层 scale。很多图表库正因为多套了一层而生成多余图例。scale true与scale auto等价说明作者刻意让 API 宽容——同一语义允许多种写法降低记忆负担。思考基于值内容的运行期推断是启发式的覆盖不了所有情况。例如字符串1既像数值又像类别此时只能靠用户显式声明兜底scale: identity。推断过程对用户不可见初次使用最容易踩的坑就是我的 fill 为什么不生成图例——因为推断判定它是字面颜色直接跳过了 color scale。这类魔法最好配一份可打开的调试输出。4.3 Scale 比例尺系统src/scales.jssrc/scales/比例尺注册表是 Plot 把数据映射到视觉值的核心// src/scales/index.js L32-L43exportconstregistrynewMap([[x,position],[y,position],[fx,position],[fy,position],[r,radius],[color,color],[opacity,opacity],[symbol,symbol],[length,length],[projection,projection]]);exportfunctionisPosition(kind){returnkindposition||kindprojection;}注册表用 7 个Symbol标记 scale 的族position/color/radius/opacity/symbol/length/projection。这种设计带来两个好处Mark 声明scale: x自动知道属于 position → 自动有对应轴、margin 自动调整。Plot 内部统一处理见 6 章比例尺推断。比例尺创建入口// src/scales.js L38-L57exportfunctioncreateScales(channelsByScale,options){constscales{};for(const[key,channels]ofchannelsByScale){constscaleOptionsoptions[key];constscalecreateScale(key,channels,{...scaleOptions});if(scale){/* populate generic options: percent, transform, insets, ... */}}returnscales;}具体实现连续型src/scales/quantitative.jslinear/sqrt/pow/log/symlog/quantile/quantize/threshold/identity时间型src/scales/temporal.jstime/utc离散型src/scales/ordinal.jsordinal/point/band双向发散src/scales/diverging.jsdiverging Sqrt/Pow/Log/Symlog 变体配色src/scales/schemes.jscategorical diverging 方案特点注册表 族标记registry用Map把 scale 名x映射到族position把名字与类别两件事解耦。能力查询代替类型判断isPosition(kind)让 Plot 在不知道具体是哪种 scale 的情况下也能回答它是不是位置类——而轴、margin、facet 的推断恰恰只关心这个。编排与实现分离scales.js只负责按 key 批量创建并回填通用选项具体映射算法在scales/*.js里两者可各自演化、互不牵制。启发用一张注册表 一组族标记替代厚厚的switch/if-else是插件化设计的最小形态。第 12 章会看到自定义 scale 只需往注册表里加一项其余代码一行都不用改。面向能力查询而非具体类型编码不要问这是不是 linear而要问它是不是 position。这样新增 scale 时消费方完全无感——这条原则可以直接迁移到任何可扩展对象设计里。思考registry是模块级单例天然全局可变写测试时容易互相污染也无法做多实例隔离虽然实际业务很少需要。族标记用Symbol而不是字符串好处是不怕命名冲突代价是调试时不直观——控制台里只能看到Symbol(position)得翻源码才知道含义。4.4 Options 选项解析src/options.jsoptions.js不实现功能而是提供贯穿全库的基础工具工具作用valueof(data, value)字符串字段名 / 函数 / 数组 / 通道对象 / 常量 → 值数组column(data, name)字段名 → 列访问器函数identity(d)返回自身默认字段访问器dataify(data)函数 → 求值后的数据range(data)[0, 1, ..., length-1]索引数组maybeValue(o)通道 shorthand 解析string → {value: string}isColor/isOpacity/isSymbol推断通道类型推断后决定走哪种比例尺理解valueof是理解 Plot DSL 的钥匙——所有用户传入的x、y、fill都会先经过它翻译成数组。特点这一层没有功能只有协议它把用户可能怎样描述一列数据这个开放问题收敛成valueof一个入口。5 种输入形式对应 5 种认知习惯函数自定义逻辑、字符串字段名、数组字面值、对象完整 channel 选项、常量广播——从最懒的写法到最精确的写法全都覆盖。dataify让数据本身也可以是函数支持惰性 / 响应式数据源例如() fetch(...)求值后才拿到数组。range(data)提供无数据也能有下标为那些只关心索引的 mark如 frame、批注 rule保留统一寻址方式。启发把多态收敛到一个入口函数是设计 DSL 性价比最高的做法上游 Mark 完全不必关心用户传了什么只管调valueof。如果你要为自己库做 DSL先定义好这个翻译器再往上加各种语法糖。“数据可以是函数”dataify这一点常被忽略但它正是 Plot 能嵌入 Observable 响应式运行时、随数据自动重绘的基础之一。思考一个函数承担 5 种语义内部必然堆叠typeof分支对调用者友好对维护者则依赖足够的测试覆盖。便利与复杂度是被转移了而不是消失了。maybeValue、maybeSymbol这类顺手做一点归一化的命名偏隐晦新读者不容易意识到它们在链路里的存在感。4.5 Context 渲染上下文src/context.jsContext 是 Mark 渲染时拿到的环境// src/plot.js L153-L184plot() 中创建 contextconstcontextcreateContext(options);constdocumentcontext.document;constsvgcreator(svg).call(document.documentElement);letfiguresvg;context.ownerSVGElementsvg;context.classNameclassName;context.projectioncreateProjection(options,subdimensions);context.pathfunction(){returngeoPath(this.projection??xyProjection(scales));};context.filterFacets(data,channels){...};context.getMarkState(mark){...};context.dispatchValue(value){...};关键能力context.document标准化的 DOMjsdom / 浏览器 / Observable runtime 都支持context.pathGeoJSON 路径生成器d3.geoPathcontext.projection地图投影如果有context.getMarkState(mark)让一个 Mark 引用另一个 Mark 的 channels/values用于 axis、tip 等特点依赖注入容器Mark 不import document、不import d3-geo而是从context取。因此同一份 Mark 代码能在浏览器、Node jsdom、Observable runtime 三种环境里跑。可选能力用可选字段表达projection/path只在 geo mark 场景下才有值普通图表拿到undefined即可无需到处特判当前是不是地图模式。getMarkState建立 mark 之间的引用轴axis、提示tip这类派生图元本身没有数据全部信息都来自被画的 mark——getMarkState就是官方认可的访问另一个 mark 状态的入口。把对外通信也纳入注入dispatchValue让库能把值 / 警告交给宿主环境而不是console.log了事。启发想让渲染代码脱离浏览器复用关键是把环境DOM、路径生成、投影、错误上报全部收敛进一个 context 对象而不是散落的全局变量或直接 import。这是渲染核心与运行时解耦的标准做法。把事件回调也注入这点尤其值得学库不假设宿主是谁只假设宿主愿意接收事件。这让同一个库既能给浏览器用也能给 Notebook / 报表引擎用。思考context既承担环境容器document、projection又顺手夹带了plot()的局部状态scales闭包、figure引用边界有些模糊——它并不是一个纯粹的只读输入环境。path/filterFacets/getMarkState/dispatchValue都挂在同一个对象上功能增长时有滑向上帝对象的风险。若继续扩张拆成context.env与context.services会更清晰。4.6 Dimensions 尺寸计算src/dimensions.js// src/dimensions.js L6-L48精简exportfunctioncreateDimensions(scales,marks,options{}){// 1) 默认 margin 所有 marks 中最大的 marginletmarginTopDefault0.5-offset;for(const{marginTop,marginRight,marginBottom,marginLeft}ofmarks){marginTopDefaultmax(marginTopDefault,marginTop);// ...}// 2) 用户选项覆盖let{marginTopmarginTopDefault,marginRight,marginBottom,marginLeft}options;// 3) 默认 width640height 自动根据 y 轴的 band count 算let{width640,heightautoHeight(scales,options,...)}options;// 4) 输出return{width,height,marginTop,marginRight,marginBottom,marginLeft};}autoHeight是个亮点Plot 根据 y 轴的 ordinal band 数自动算 height让条形图多高 多条不需要用户手算。特点尺寸是推导出来的不是要求出来的默认 margin 取所有 mark 声明里的最大值默认 height 由 y 轴 band 数量决定用户只在需要时才覆盖。覆盖顺序单向、清晰先算 default再让options覆盖绝无用户与推导互相打架的情况。“宽度给定、高度自适应”符合多数表格化图表的直觉横向宽度受屏幕限制纵向高度应由数据条数决定。启发好的默认值设计应当做到常见情况零配置特殊情况可覆盖且覆盖方向单一。这条准则比任何具体默认数值都重要。autoHeight把条形图高度应随条目数增长这种业务常识编码进了库——用户不必记住经验规则。这正是库相对手写 D3 的价值所在替用户记住经验。思考autoHeight需要读取 scales 的具体形态ordinal band意味着dimensions与scales存在隐式耦合一旦 scale 类型或 band 语义变化高度的计算假设也要跟着改。margin 取所有 mark 的最大值在叠加多个 mark 时是保守策略可能让某一侧留白偏大、需要手动调——这是安全默认换来的轻微浪费。4.7 小结六个抽象的设计手法对照抽象一句话定位核心设计手法留给你的启发Mark图形的声明与生命周期模板方法 组合transform 钩子先定协议再填实现Channel数据列 → 美学的桥梁集中式类型推断约定优于配置要收口到一个函数Scale值域 → 视觉域的映射注册表 能力查询族标记问能力别问类型Options用户输入的翻译器多态收敛到单一入口DSL 的性价比来自入口收敛Context渲染的运行时环境依赖注入环境与核心解耦才能跨端Dimensions画布尺寸的推导安全默认 单向覆盖常见情况零配置六个抽象合起来回答的是同一个问题如何让用户只描述意图与程序精确执行这两端对接。Plot 的答案可以归纳为三句话——用推断消除配置、用注册表替代分支、用注入隔离环境。第 5 章将看它们如何被plot()串成一条流水线。
返回列表