实现你自己的图表
此教程将尝试给 Pintora 扩展一个 Mermaid.js 风格的饼图。
info
你可于 github 查看此教程的代码。
tip
在实现自定义图表之前,请先了解 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
Pintora 的内置图表使用 nearley.js 作为 parser generator,有一些通用的语法片段(例如 @param
和 @config
指令),关于如何使用 nearley.js 和复用语法规则,今后会写另一篇教程来阐述。
艺术家 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 sectorMark = pintora.util.makeMark('path', {
path: [
['M', circleCenter.x, circleCenter.y],
['l', arcStartX, arcStartY],
[
'a',
radius,
radius,
currentRad,
0,
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 页面中简单测试一下效果。
<section>
<div class="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>