1

SVG charts

2

I really enjoy building different types of charts. To me, it feels like the programming equivalent of painting. For this website, I have

3

built a small collection of utility functions. I use these functions to draw all of the charts on this website. In this post, I will

4

be sharing the code for some of them, as well as some tips and tricks.

5

6

Coordinates

7

I started with functions for converting my data points into coordinates. Normalizing these values is crucial to ensure they fit within the

8

boundaries of the target element.

9

10

The conversion requires the width and height of the element that is going to be wrapping your chart. I also wanted the charts to adapt to

11

the screen size. Therefore, I used the resize observer API to extract this information at runtime.

12

13

I didn't want to set the scale of the y-axis to a fixed value either. Personally, I think you can improve the aesthetics of the chart by not

14

drawing too close to the edges. I wrote a function that would find the largest value in my array, and multiply it by a padding factor. This

15

value would represent the charts apex.

16

17

With variables for the maximum value, width, and height I performed linear transformations to retrieve the y-coordinates.

18

19
const y = (value / maximumValue) * height
20

21

However, we must bear in mind that the 0,0 value is at the top left corner. To account for this, I had to take the height of the chart and

22

subtract the y value:

23

24
const adjustedY = height - y
25

26

To set the x-coordinates I took the width of the chart, and subtracted a padding. I then divided that value by ones less than the total

27

number of data points. This gave me the space increment between each data point on the x-axis:

28

29
const spaceIncrement = (width - horizontalPadding) / (data.length - 1)
30

31

The last thing I had to do in order to get the coordinates was to iterate through the array, multiply the increment with a particular

32

value's index, and add half of the horizontal padding.

33

34

Drawing

35

Now, with the coordinates in place, you can start to play around with different ways of connecting them.

36

37

I began by reducing the array of coordinates into a sequence of instructions. To draw a svg path you have to prefix the initial coordinate

38

with an uppercase M, an abbreviation for move. Then, you are able to draw lines to the remaining coordinates by using L, which stands

39

for line:

40

41
const pathString = coordinates.reduce(
42
  (acc, cur, idx) => (idx === 0 ? `M${cur.join(' ')} ` : `${acc} L${cur.join(' ')}`),
43
  '',
44
)
42

43

Here is a screenshot of the path:

44

45
SVG Line Chart
SVG line chart using straight lines
46

47

To improve the visual appeal of the chart I wanted to make the lines smoother. Instead of using L, for drawing straight lines, I used

48

C to draw cubic bezier curves. I still used the same reduce function to create the path, but with one minor adjustment. I replaced the

49

second branch of the ternary with a function that would inject four additional coordinates for the control points. The control points are

50

used to smoothen the slope of the curve. I also defined a variable to determine the degree of line curvature:

51

52
const smoothing = 0.2
53
const pathString = coordinates.reduce(
54
  (acc, cur, idx) => (idx === 0 ? `M${cur.join(' ')} ` : `${acc} C${bezierCurve(e, i, a, smoothing)}`
55
  '',
56
)
57
 
58
function bezierCurve(cur: Array<number>, idx: number, arr: Array<Array<number>>, smoothing: number) {
59
  const previousPoint = arr[idx - 2]
60
  const nextPoint = arr[idx + 1]
61
  const startControlPoint = controlPoint(arr[idx - 1], previousPoint, cur, false, smoothing)
62
  const endControlPoint = controlPoint(cur, arr[idx - 1], nextPoint, true, smoothing)
63
  return `${startControlPoint[0]},${startControlPoint[1]} ${endControlPoint[0]},${endControlPoint[1]} ${cur[0]},${cur[1]}`
64
}
53

54

Calculating the control points requires a little bit of trigonometry:

55

56
function controlPoint(cur: Array<number>, prev: Array<number>, next: Array<number>, reverse: boolean, smoothing: number) {
57
  // If this is the first or last indexes of the array we will
58
  // anchor the control points to the current value instead
59
  const pointBefore = prev ?? cur
60
  const pointAfter = next ?? cur
61
  // Get the length and angle of the line
62
  const [lineLength, lineAngle ] = lineLengthAndAngle(pointBefore, pointAfter)
63
  // To reverse the line we can use Math.PI (which is equivalent
64
  // of 180 degrees) to make it point in the opposite direction
65
  const angle = lineAngle + (reverse ? Math.PI : 0)
66
  // This calculates the distance from the current point to the
67
  // control point. We multiply the distance by a smoothness factor.
68
  // This determines how "tight" or "loose" the curve is going to feel
69
  const length = lineLength * smoothing
70
  // Now, we'll just have to find the coordinates of the control points. We can
71
  // use Math.cos and Math.sin to achieve this based on the length and angle
72
  const x = cur[0] + Math.cos(angle) * length
73
  const y = cur[1] + Math.sin(angle) * length
74
  return [x, y]
75
}
57

58
function lineLengthAndAngle(pointA: Array<number>, pointB: Array<number>) {
59
  // Calculate the horizontal and vertical distance between the two points
60
  const deltaX = pointB[0] - pointA[0]
61
  const deltaY = pointB[1] - pointA[1]
62
  return [
63
    // We can utilize Math.hypot to compute what is known as the Euclidean distance.
64
    // It represents the straight-line distance between our deltaX and deltaY
65
    Math.hypot(deltaX, deltaY),
66
    // The angle is going to be equal to the radians between the two points
67
    Math.atan2(deltaY, deltaX),
68
  ]
69
}
59

60

Here is a screenshot of the same path being drawn with bezier curves:

61

62
SVG Line Chart
SVG line chart using bezier curves
63

64

For the mobile version of the line chart, as well as the radar chart, I added a boolean to signal whether or not the chart should be

65

enclosed. If true, I make sure to connect the last and first coordinate.

66

67

For the radar chart this is being done by drawing another cubic bezier curve:

68

69
SVG Radar Chart
SVG radar chart
70

71

And for the mobile version of the line chart, I used a couple of straight lines instead:

72

73
SVG Line Chart Mobile
SVG radar chart
74

75

Adding animations

76

I wanted the charts to feel like they were trying out different shapes before making one last transition to their final form.

77

78

To avoid performance issues I created a function that would calculate enough shapes to accommodate a loading time of 3 seconds. Next, I fed

79

these shapes to another function where I used interpolation to calculate all of the coordinates that would be required to transition the

80

chart through each form at 60 frames per second.

81

82

I also added some randomness to the curvature of the lines. This, in my opinion, made the animations feel more "alive".

83

84

Here is a GIF that displays the radar chart animation:

85

86
Radar chart
Radar chart loading animation
87

88

If you want to see the animations for the other charts, you can click on here to make the website open in a new tab (which will replay the

89

animations).

89

90

The end

91

I usually tweet something when I've finished writing a new post. You can find me on Twitter

92

by clicking  here.

normalintroduction.md
||153:23

Recently Edited

Recently Edited

File name

Tags

Time to read

Created at

context

  • go
  • context
8 minutes2024-02-28

circular-buffers

  • go
  • concurrency
  • data processing
5 minutes2024-02-04

go-directives

  • go
  • compiler
  • performance
4 minutes2023-10-21

async-tree-traversals

  • node
  • trees
  • graphs
  • typescript
19 minutes2023-09-10

All Files

All Files

  • go

    5 files

  • node

    2 files

  • typescript

    1 file

  • frontend

    1 file

  • workflow

    7 files