DEV Community

Daniel Chahla
Daniel Chahla

Posted on

SVG charts from scratch in 2024 (a better AWS visualization system)

If someone asked, “How does this picture make you feel?” and presented the following image, I would respond something like: the company running the world’s infrastructure can’t make a responsive website.

sad AWS on mobile

Backend and Dev-Ops are hard. So is UX. I’m sick of people being “above” visual design, but prefer using a Mac.

You have an opinion. You like things; it’s why you use zsh over shell, VS Code over Notepad++, etc.

So, don’t throw shade at a discipline you have successfully avoided throughout your career. Embrace the things you love; show it in your work.

arnold

God forbid someone wants to check the status of an alert while not on a computer. It’s 2024; let’s build something better. To do that, we must start with some requirements.

The charts shall…

  • Be dynamically constructed as SVGs on the client’s side.
  • Allow for the inspection of a specific point.
  • Have proper labels with units of measure.
  • Print data clearly for all devices.
  • Be able to invalidate cached data and re-render.
  • Be a drop-in using vanilla js, so there are no excuses, licenses, or versions.
  • Not make you want to switch to Azure.

Before building, you will need a JSON-valid flat array of the data you want to chart. This is the simplest way to be on the same page and focus on the hard part 😉. Here is an example of the data we will be using:

// metrics.json
[
{
"url": "https://weather.com",
"content-size": 0,
"nanoseconds": 4890831,
"seconds": 0.004890831,
"bytes-per-second": 0
},
{
"url": "https://amazon.com",
"content-size": 6591,
"nanoseconds": 617613938,
"seconds": 0.617613938,
"bytes-per-second": 10671.71511922712
},
{
"url": "https://sugarbeats.com",
"content-size": 1591459,
"nanoseconds": 893689449,
"seconds": 0.893689449,
"bytes-per-second": 1780774.0728960983
},
{
"url": "https://youtube.com",
"content-size": 868768,
"nanoseconds": 968598554,
"seconds": 0.968598554,
"bytes-per-second": 896932.9929435349
}
]

data[0]
├── string "url": "https://weather.com"
└── number "content-size": 0,
└── number "nanoseconds": 4890831,
└── number "seconds": 0.004890831,
└── number "bytes-per-second": 0
Enter fullscreen mode Exit fullscreen mode

Now that we are on the same page about how the data should look, let’s build!

First, we will need an HTML element to attach our logic. While this can be dynamically created in JavaScript, I prefer explicitly setting the width and height to see how it fits on mobile immediately. Say, iPhone SE, as seen below.

Image description

<svg id="barChartNanoseconds" class="chart" width="500" height="340"></svg>

Enter fullscreen mode Exit fullscreen mode

To avoid recreating a situation in which we are plotting points but cannot determine their units of measure.

Let’s tackle the Y-labels next.

The calculateYLabels function below determines the position of our chart’s Y-axis labels. It takes in the data and computes the range of values present within the dataset. Once the range is established, it evenly divides the available vertical space (in Pixels) to generate a list of labels that accurately represent the data points along the Y-axis. This ensures that the labels are evenly distributed and appropriately scaled, regardless of the specific values in the dataset. At the same time, maximizing the utilization of the chart’s visual space.

const calculateYRangeLong = (metric, maxLabels = 10) => {
  const maxValue = Math.ceil(Math.max(...data.map(item => item[metric])))
  const step = Math.ceil(maxValue / maxLabels)
  const range = Array.from({ length: maxLabels + 1 }, (_, i) => i * step)
  return range
}

const calculateYLabels = metric => {
  const range = calculateYRangeLong(metric) 
  console.log(range)
//[0, 96859856, 193719712, 290579568, ..., 871738704, 968598560]

// 300px : svg.height.baseVal.value === 340; 340-(4*10)px per data point
  const yScale = 300 / range.length 
  return range.map((value, index) => ({
      y: 300 - index * yScale, 
      label: value
  }))
}
Enter fullscreen mode Exit fullscreen mode

I’ve broken this task up into two functions for readability. calculateYRangeLong returns a range (Array) with even steps between min and max values.

It could be written several different ways. Spread operators are just so hot right now. Of course, maxLabels could be amended, but for the sake of brevity and mobile, I’m going to cap mine at ten good notches with 11 Pixels each.

const yLabels = calculateYLabels("nanoseconds");
console.log(yLabels);
// [
//     {
//         "y": 300, // distance from top with room for title
//         "label": 0
//     },
//     {
//         "y": 272.72727272727275, // distance from top with room for label 0 
//         "label": 96859856
//     },
....
//     {
//         "y": 27.272727272727252, // distance from top of last data point
//         "label": 968598560
//     }
// ]
Enter fullscreen mode Exit fullscreen mode

Finally, we put it all together.

The createBarChart function calculates the spacing between each data point on the X-axis to ensure optimal distribution within the chart’s width. This calculation is based on the width of the SVG element and the number of data points to be displayed, allowing for automatic adjustment with dynamic datasets.

const createBarChart = (svgId, choice, unit) => {
  const svg = document.getElementById(svgId)
  const yLabels = calculateYLabels(choice)

  yLabels.forEach((label, index) => {
      const newYLabel = document.createElementNS(
          'http://www.w3.org/2000/svg',
          'text'
      )
      newYLabel.setAttribute('id', `label${index}`)
      newYLabel.setAttribute('x', 0)
      newYLabel.setAttribute('y', label.y)
      if (label.label === 0 && unit) {
          newYLabel.textContent = label.label + ` (${unit})`
      } else {
          newYLabel.textContent = label.label.toString().substring(0, 79)
      }
      newYLabel.setAttribute('font-size', '11')
      svg.appendChild(newYLabel)
  })
  const histogram = data.reduce((acc, obj) => (Object.entries(obj).forEach(([key, value]) => key !== 'url' && (acc[key] = (acc[key] || 0) + 1)), acc), {});
  const labelXPadding = 7 //space between rect-label and rect
  const labelXMargin = 93 //max size of our Y-labels in px
  const spaceBetween = (svg.width.baseVal.value / histogram[choice]) - labelXPadding

  data.forEach((item, index) => {
      const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
      const text = document.createElementNS('http://www.w3.org/2000/svg', 'text')
      const recty = item[choice] * calculateYScale(choice)

      rect.setAttribute('id', `rect${index}`)
      rect.setAttribute('x', index * spaceBetween + labelXMargin + labelXPadding)
      rect.setAttribute('y', 300 - recty)
      rect.setAttribute('width', 10)
      rect.setAttribute('height', recty)
      svg.appendChild(rect)

      text.setAttribute('x', index * spaceBetween + labelXMargin)
      text.setAttribute('y', 300)
      text.style.transform = 'rotate(-90deg)'
      text.style.transformOrigin = `${index * spaceBetween + labelXMargin}px ${300}px`
      text.textContent = item.url
      svg.appendChild(text)
  })


  const svgRect = svg.getBoundingClientRect()
  const ChartLabel = document.createElementNS(
      'http://www.w3.org/2000/svg',
      'text'
  )
  ChartLabel.setAttribute('x', svgRect.width / 2)
  ChartLabel.setAttribute('y', svgRect.height - 330)
  ChartLabel.classList.add('tableTitle')
  ChartLabel.textContent = `[${choice}]`
  svg.appendChild(ChartLabel)
}


createBarChart('barChartNanoseconds', 'nanoseconds', 'ns');
Enter fullscreen mode Exit fullscreen mode

It’s important to note that adjustments may be needed based on the amount of data and design requirements of your chart. For instance, in a time-series chart with numerous points, you might consider adjusting the width of the data rectangle (10 pixels) and dropping the X-axis label for better visualization clarity. Or… incorporate a horizontal scroll!

Fine-tuning: If you are a stickler like me, calculateYScale provides a mechanism for adjusting the vertical positioning of data points relative to the Y-axis, ensuring accurate representation and alignment within the corresponding Y-Axis Label.

const calculateYScale = metric => {
    const maxValue = Math.max(...data.map(item => item[metric]))
    return 278 / maxValue // 11 px font size * 2 (0 label + title of chart)
}
Enter fullscreen mode Exit fullscreen mode

Our finished product.

finished product

You can play with the Codepen

Or, see it in action

Top comments (0)