Write a custom diagram
This tutorial will try to extend a Mermaid.js-style pie chart diagram to Pintora.
You can check out the code for this tutorial on github.
Before implementing custom diagrams, please learn about Pintora's technical brief.
Basic syntax and DiagramIR
pie
  title Bag of Fruits
  "apple" 5
  "peach" 6
  "banana" 2
- the diagram description starts with pie
- the content after titlewill be the title of the diagram
- each row satisfies the "<name>" <number>format, wherenumberneeds to be a positive number
The following is the definition of the logical diagram data PieChartDiagramIR.
export type Item = {
  name: string
  count: number
}
export type PieChartDiagramIR = {
  title: string
  items: Item[]
  sum: number
}
First glance at what is needed to register a diagram type
Based on syntax rule 1 above, we can derive the conditions for determining a diagram as a pieChart, described using the regular expression /^\s*pie\s*\n/. pintora will check the input text using the pattern it provides according to the order in which the diagrams are registered, using the first matching diagram for subsequent processing.
parser and artist we have not yet implemented, and will describe them in the following.
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)
The parser
Since the syntax is really simple, we can quickly implement a parser using regular expressions based on line content matching:
- TITLE_REGEXPcorresponds to syntax rule 2
- RECORD_REGEXPcorresponds to syntax rule 3- Currently we only accepts the contents of double quotes as item names
- Accepts simple positive numbers with decimal points, does not support scientific notation
 
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
Advanced: Using nearley.js to generate parser
Pintora's built-in diagrams use nearley.js as parser generator, and has some common grammar rules (such as the @param and @config directives), we will write another tutorial about it in the future.
The latest version of pintora-diagram-pie-chart uses a forked version of nearley to generate the parser.
The artist
The amount of code for artist is usually the largest part of the diagram implementation, so we will break it down step by step.
Basic structure
The artist is responsible for converting the parser-generated diagramIR into the visual representation format GraphicsIR, so the start and end of the implementation are shown below.
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
Also declare the following as the base diagram configuration that will be used when drawing the elements.
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,
}
Draw the title
- The title text is centered horizontally based on itself (achieved with textAlign: 'center'), andxis the same as the center of the pie chart that will be drawn later
- The presence of the title affects the vertical position of the pie chart, and we use circleStartYto mark the starting position of the pie chart in the vertical direction
    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
    }
Draw each item
This part is tedious with details, it can be summarized as: each item includes the sectors in the pie chart as well as the legend on the right, so corresponding to each part, it is necessary to:
- Plot the sector with an angle proportional to the number of items in the overall sum, denoted hereafter bysectorMark. It is aPathtype marker, and the shape is described bypath. For the syntax of paths, see the document Paths - SVG: Scalable Vector Graphics | MDN.
- pLabelis a text type label that displays the percentage associated with the area in the sector.
- legendSquareis a small square of the same color as the sector, displayed in the legend area to the right of the pie chart.
- legendLabelappears to the right of- legendSquareand shows the name of the legend.
- When calculating the layout, pintora.util.calculateTextDimensions(text, fontConfig)is used to calculate the size of the visual area of the text, which is used to determine the placement of subsequent elements.
After generating all the tags needed for a single item, group them into itemGroup and add itemGroup to rootMark.children. The Group can be nested appropriately according to your needs. A reasonable grouping is convenient for debugging on the one hand, and on the other hand you can use some special fields, such as group.matrix to do geometric transformations on the whole group.
Notice the class attribute on itemGroup, which will be carried in the SVG output and is also a little trick to facilitate debugging.
    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)
    })
Calculate the final size of the diagram
At this point, the final graphicsIR is complete with all the required data.
    const diagramWidth = maxLabelRight + conf.diagarmPadding
    const graphicsIR: GraphicsIR = {
      mark: rootMark,
      width: diagramWidth,
      height: circleStartY + 2 * radius + conf.diagarmPadding,
      bgColor: conf.diagramBackgroundColor,
    }
Next step: Adding config to the diagram
For basic concepts of config, see the Configuration documentation.
With Typescript's type enhancement feature, you can extend the interface PintoraConfig in the @pintora/core package to add some pie-chart specific configs, and can be saved and accessed by the key pie.
declare module '@pintora/core' {
  interface PintoraConfig {
    pie: {
      diagarmPadding: number
      diagramBackgroundColor: string
      circleRadius: number
      pieColors: string[]
    }
  }
}
Set the default config for your diagram
Before registering the diagram, use pintora.setConfig({ pie: { ...defaultConfig } }) to set the default config for 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 },
+})
+
Some changes to the artist
-  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]
Testing the diagram
We can use any bundler to package the source code of this diagram as dist/pintora-diagram-pie-chart.umd.js, which can be tested in an html page to see the effect.
The pintora-diagram-pie-chart.umd.js will register the custom diagram to pintora, please make sure that global pintora object is loaded before the custom diagram script loads.
  <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>
Show time
You can check the online dev demo on stackblitz.