实现你自己的图表
此教程将尝试给 Pintora 扩展一个 Mermaid.js 风格的饼图。
你可于 github 查看此教程的代码。
在实现自定义图表之前,请先了解 Pintora 的技术简介。
基本语法和 DiagramIR
pie
  title Bag of Fruits
  "apple" 5
  "peach" 6
  "banana" 2
- 以 
pie为起始标识 title后的内容会作为图表的标题- 每一行记录满足 
"<name>" <number>的格式,其中number需要是正数 
以下为图表逻辑数据 PieChartDiagramIR 的定义。
export type Item = {
  name: string
  count: number
}
export type PieChartDiagramIR = {
  title: string
  items: Item[]
  sum: number
}
先看一 眼注册图表需要什么
基于以上的语法规则 1,我们可以得出将图表判定为 pieChart 的条件,使用正则表达式 /^\s*pie\s*\n/ 来描述。pintora 会根据按照图表的注册顺序,使用其提供的 pattern 对输入文本进行检查,使用第一个匹配的图表来进行后续的处理。
parser 和 artist 我们还没有实现,将在下文中说明。
import { IDiagram } from '@pintora/core'
import pintora from '@pintora/standalone'
import { PieChartDiagramIR } from './type'
import parser from './parser'
import artist from './artist'
const pieChartDiagram: IDiagram<PieChartDiagramIR> = {
  pattern: /^\s*pie\s*\n/,
  parser,
  artist,
}
pintora.diagramRegistry.registerDiagram('pieChart', pieChartDiagram)
语法解析器 parser
由于语法真的很简单,我们可以使用基于行内容匹配的正则表达式快速实现 parser:
TITLE_REGEXP对应于语法规则 2RECORD_REGEXP对应于语法规则 3- 目前只接受双引号内的内容作为记录名
 - 接受简单的正数,可以带有小数点,不支持科学计数法
 
import { IDiagramParser } from '@pintora/core'
import { PieChartDiagramIR, Item } from './type'
const TITLE_REGEXP = /^title\s*(.*)/
const RECORD_REGEXP = /^\"(.*)\"\s+([\d\.]+)/
const parser: IDiagramParser<PieChartDiagramIR> = {
  parse(input: string) {
    const ir: PieChartDiagramIR = {
      title: '',
      items: [],
      sum: 0,
    }
    const lines = input.split('\n')
    for (const line of lines) {
      let match
      const trimmedLine = line.trim()
      if (match = TITLE_REGEXP.exec(trimmedLine)) {
        ir.title = match[1]
        continue
      }
      if (match = RECORD_REGEXP.exec(trimmedLine)) {
        const item: Item = {
          name: match[1],
          count: parseFloat(match[2]),
        }
        ir.items.push(item)
        continue
      }
    }
    ir.sum = ir.items.reduce((sum, item) => sum + item.count, 0)
    return ir
  }
}
export default parser
进阶: 使用 nearley.js 生成 parser
Pintora 的内置图表使用 nearley.js 作为 parser generator,有一些通用的语法片段(例如 @param 和 @config 指令),关于如何使用 nearley.js 和复用语法规则,今后会写另一篇教程来阐述。
pintora-diagram-pie-chart 的最新代码 使用一个 fork 了的 nearley 版本 来生成 parser。
艺术家 artist
artist 的代码量一般是图表实现中最多的部分,我们将步步分解。
基础结构
artist 负责将 parser 生成的 diagramIR 转换为视觉表达格式 GraphicsIR,因此实现的头和尾如下所示。
import { IDiagramArtist, GraphicsIR, Group } from '@pintora/core'
import pintora, { IFont } from '@pintora/standalone'
import { PieChartDiagramIR } from './type'
const pieChartArtist: IDiagramArtist<PieChartDiagramIR> = {
  draw(diagramIR) {
    const rootMark: Group = {
      type: 'group',
      children: [],
    }
    // ... TBD
    const graphicsIR: GraphicsIR = {
      mark: rootMark,
      width,
      height,
      bgColor,
    }
    return graphicsIR
  },
}
export default pieChartArtist
同时声明以下作为基础的图表配置,绘制元素的时候会使用。
const PIE_COLORS = [
  '#ecb3b2',
  '#efc9b3',
  '#f5f6b8',
  '#c6f4b7',
  '#bce6f5',
  '#cdb2f2',
  '#ecb4ee',
]
const LEGEND_SQUARE_SIZE = 20
const LEGEND_FONT: IFont = {
  fontSize: 14,
  fontFamily: 'sans-serif',
  fontWeight: 'normal',
}
type PieConf = {
  diagarmPadding: number
  diagramBackgroundColor: string
  circleRadius: number
}
const conf: PieConf = {
  diagarmPadding: 10,
  diagramBackgroundColor: '#F9F9F9',
  circleRadius: 150,
}
绘制标题
- 标题文字基于自身水平居中(通过 
textAlign: 'center'实现),且x与之后会绘制的饼图圆心相同 - 标题的存在,会影响饼图的垂直位置,我们使用 
circleStartY标记饼图在垂直方向上的起始位置 
    const radius = conf.circleRadius
    let circleStartY = conf.diagarmPadding
    let circleStartX = conf.diagarmPadding
    if (diagramIR.title) {
      const titleMark = pintora.util.makeMark('text', {
        text: diagramIR.title,
        x: circleStartX + radius,
        y: circleStartY + 10,
        fill: 'black',
        fontSize: 16,
        fontWeight: 'bold',
        textBaseline: 'middle',
        textAlign: 'center',
      })
      rootMark.children.push(titleMark)
      circleStartY += 30
    }
绘制每条记录
这一部分较为繁琐,简单概括,每条记录包括饼图中的扇形以及右边的图例(legend),因此对应每一部分,都需要:
- 绘制扇形区域,角度与记录的数字在总体 
sum中的占比成正比,以下使用sectorMark表示。它是一个Path类型的标记,形状由path描述,关于路径的语法,可以查看文档 Paths - SVG: Scalable Vector Graphics | MDN。 pLabel是一个文本类型的标记,在扇形区域中显示该区域相关的百分比。legendSquare为与扇形颜色相同的小方块,在饼图右侧的图例区域显示。legendLabel出现在legendSquare右侧,显示图例的名字。- 在计算布局时,使用了 
pintora.util.calculateTextDimensions(text, fontConfig)来计算文本的视觉区域大小,用于决定后续元素的摆放位置。 
生成完单条记录需要的所有标记后,将它们归至 itemGroup 中,再将 itemGroup 添加进 rootMark.children 里。Group 可以根据需求做合适的嵌套,合理的分组一方面方便调试,另一方面可以使用一些特殊字段,例如 group.matrix 对整个组做几何变换。
注意到 itemGroup 上的 class 属性,它在 SVG 输出时会带上,也是方便调试的一个小技巧。
    diagramIR.items.forEach((item, i) => {
      const fillColor = PIE_COLORS[i % PIE_COLORS.length]
      const rad = (item.count / diagramIR.sum) * RAD_OF_A_CIRCLE
      const destRad = currentRad + rad
      const arcStartX = radius * Math.cos(currentRad)
      const arcStartY = radius * Math.sin(currentRad)
      const arcEndRel = {
        x: radius * Math.cos(destRad),
        y: radius * Math.sin(destRad),
      }
      const largeArcFlag = rad > Math.PI ? 1 : 0
      const sectorMark = pintora.util.makeMark('path', {
        path: [
          ['M', circleCenter.x, circleCenter.y],
          ['l', arcStartX, arcStartY],
          [
            'a',
            radius,
            radius,
            currentRad,
            largeArcFlag,
            1,
            arcEndRel.x - arcStartX,
            arcEndRel.y - arcStartY,
          ],
          ['Z'],
        ],
        stroke: '#333',
        fill: fillColor,
      })
      // draw percentage label
      const pLabelX =
        circleCenter.x + (radius * Math.cos(currentRad + rad / 2)) / 2
      const pLabelY =
        circleCenter.y + (radius * Math.sin(currentRad + rad / 2)) / 2
      const pLabel = pintora.util.makeMark('text', {
        text: `${Math.floor((100 * item.count) / diagramIR.sum)}%`,
        fill: 'black',
        x: pLabelX,
        y: pLabelY,
        textAlign: 'center',
        textBaseline: 'middle',
      })
      // draw legend
      const legendSquare = pintora.util.makeMark('rect', {
        fill: fillColor,
        width: LEGEND_SQUARE_SIZE,
        height: LEGEND_SQUARE_SIZE,
        x: legendStart.x,
        y: currentLabelY,
      })
      const labelX = legendStart.x + LEGEND_SQUARE_SIZE + 5
      const legendLabel = pintora.util.makeMark('text', {
        text: item.name,
        fill: 'black',
        x: labelX,
        y: currentLabelY,
        ...(LEGEND_FONT as any),
        textBaseline: 'top',
      })
      currentRad = destRad
      currentLabelY += LEGEND_SQUARE_SIZE + 5
      const labelDims = pintora.util.calculateTextDimensions(
        item.name,
        LEGEND_FONT
      )
      maxLabelRight = Math.max(maxLabelRight, labelX + labelDims.width)
      const itemGroup: Group = {
        type: 'group',
        children: [sectorMark, pLabel, legendSquare, legendLabel],
        class: 'pie__item'
      }
      rootMark.children.push(itemGroup)
    })
计算图表的最终大小
到这里,最终的 graphicsIR 所需数据便全部完成了。
    const diagramWidth = maxLabelRight + conf.diagarmPadding
    const graphicsIR: GraphicsIR = {
      mark: rootMark,
      width: diagramWidth,
      height: circleStartY + 2 * radius + conf.diagarmPadding,
      bgColor: conf.diagramBackgroundColor,
    }
下一步: 给图表加上可配置项
关于配置的基本概念,请参阅 Config 相关文档。
通过 Typescript 的类型增强特性,你可以扩展 @pintora/core 包中的 interface PintoraConfig,给这个类型加上一些饼图特有的配置项,使用 pie 作为键名来存取。
declare module '@pintora/core' {
  interface PintoraConfig {
    pie: {
      diagarmPadding: number
      diagramBackgroundColor: string
      circleRadius: number
      pieColors: string[]
    }
  }
}
设置默认配置
在注册图表时,使用 pintora.setConfig({ pie: { ...defaultConfig } }) 来给 pie 设置默认值。
import pintora, { IFont, PintoraConfig } from '@pintora/standalone'
 import { PieChartDiagramIR } from './type'
 
-const PIE_COLORS = [
+const DEFAULT_PIE_COLORS = [
   '#ecb3b2',
   '#efc9b3',
   '#f5f6b8',
@@ -19,21 +19,25 @@ const LEGEND_FONT: IFont = {
   fontWeight: 'normal',
 }
 
-type PieConf = {
-  diagarmPadding: number
-  diagramBackgroundColor: string
-  circleRadius: number
-}
+type PieConf = PintoraConfig['pie']
 
-const conf: PieConf = {
+// default config
+const defaultConfig: PieConf = {
   diagarmPadding: 10,
   diagramBackgroundColor: '#F9F9F9',
   circleRadius: 150,
+  pieColors: DEFAULT_PIE_COLORS
 }
 
+pintora.setConfig({
+  pie: { ...defaultConfig },
+})
+
更改 artist
此时 draw 方法从 PIE_COLORS 改为 conf.pieColors。
-  draw(diagramIR) {
+  draw(diagramIR, config) {
+    const conf: PieConf = Object.assign({}, pintora.getConfig().pie, config || {})
+
     const rootMark: Group = {
       type: 'group',
       children: [],
@@ -74,7 +78,7 @@ const pieChartArtist: IDiagramArtist<PieChartDiagramIR> = {
     let currentLabelY = legendStart.y
     let maxLabelRight = 0
     diagramIR.items.forEach((item, i) => {
-      const fillColor = PIE_COLORS[i % PIE_COLORS.length]
+      const fillColor = conf.pieColors[i % conf.pieColors.length]
测试更改全局配置的效果
假设 你在渲染图表之前,通过以下方式来更改了部分设定。那么下次再渲染的时候,artist 就会画出一个半径 300
pintora.default.setConfig({
  pie: {
    circleRadius: 300,
  }
})
测试图表
我们使用任意 bundler 将此图表的源码打包为 dist/pintora-diagram-pie-chart.umd.js,可以在 html 页面中简单测试一下效果。
pintora-diagram-pie-chart.umd.js 会将自定义图表注册到 pintora,请确保全局的 pintora 对象在自定义图表脚本加载前已经加载。
  <section>
    <div className="pintora">
      pie
        title Bag of Fruits
        "apple" 5
        "peach" 6
        "banana" 2
    </div>
  </section>
  <script src="https://cdn.jsdelivr.net/npm/@pintora/standalone/lib/pintora-standalone.umd.js"></script>
  <script src="./dist/pintora-diagram-pie-chart.umd.js"></script>
  <script>
    pintora.default.initBrowser()
  </script>
看看效果
前往 stackblitz 上的在线开发 demo 查看效果.