1

SVG charts

2

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

3

website, I have built a small collection of utility functions. I use these functions to draw all of the charts on

4

this website. In this post, I will 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

8

they fit within the 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

11

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

12

runtime.

13

14

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

15

of the chart by not drawing too close to the edges. I wrote a function that would find the largest value in my array,

16

and multiply it by a padding factor. This value would represent the charts apex.

17

18

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

19

y-coordinates.

20

21
const y = (value / maximumValue) * height
22

23

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

24

height of the chart and subtract the y value:

25

26
const adjustedY = height - y
27

28

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

29

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

30

31
const spaceIncrement = (width - horizontalPadding) / (data.length - 1)
32

33

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

34

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

35

36

Drawing

37

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

38

39

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

40

initial coordinate with an uppercase M, an abbreviation for move. Then, you are able to draw lines to the remaining

41

coordinates by using L, which stands for line:

42

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

45

Here is a screenshot of the path:

46

47
SVG Line Chart
SVG line chart using straight lines
48

49

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

50

straight lines, I used C to draw cubic bezier curves. I still used the same reduce function to create the path, but

51

with one minor adjustment. I replaced the second branch of the ternary with a function that would inject four additional

52

coordinates for the control points. The control points are used to smoothen the slope of the curve. I also defined a

53

variable to determine the degree of line curvature:

54

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

57

Calculating the control points requires a little bit of trigonometry:

58

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

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

63

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

64

65
SVG Line Chart
SVG line chart using bezier curves
66

67

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

68

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

69

70

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

71

72
SVG Radar Chart
SVG radar chart
73

74

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

75

76
SVG Line Chart Mobile
SVG radar chart
77

78

Adding animations

79

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

80

form.

81

82

To avoid performance issues I created a function that would calculate enough shapes to accommodate a loading time of 3

83

seconds. Next, I fed these shapes to another function where I used interpolation to calculate all of the coordinates

84

that would be required to transition the chart through each form at 60 frames per second.

85

86

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

87

88

Here is a GIF that displays the radar chart animation:

89

90
Radar chart
Radar chart loading animation
91

92

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

93

(which will replay the animations).

93

94

The end

95

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

96

by clicking 

normalintroduction.md
||153:23

Recently Edited

Recently Edited

File name

Tags

Time to read

Created at

context

  • go
  • context
9 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