Hajime, the duck guy

Monday, March 10, 2025, by Hajime Yamasaki Vukelic

About this series

Step-by-step guide for building applications using nothing but browser APIs – no build, no tools, no frameworks

  1. Introduction to vanilla, goals, principles
  2. Requirements, architecture, content structure
  3. SVG spritesheet for icons
  4. Data molding, HTTP requests, basic event handling
  5. Storage, advanced event handling
  6. MPA, teamwork and shared code, separation of concerns
  7. Webfonts, asset preloading, responsive grid layouts
  8. Responsive tables using a grid layout with subgrids
  9. Toolless lazy loading and micro-frontends (part 1)
  10. Toolless lazy loading and micro-frontends (part 2)
  11. Reverting back to classic MPA and comparison with micro-frontend
  12. Code cleanup and vanilla CSS-only view transitions
  13. Accessible tabbed interface and a simple feature flag
  14. Visualization using vanilla SVG (part 1)
  15. Visualization using vanilla SVG (part 2)
  16. Visualization using vanilla SVG (part 3)
  17. PWA and offline mode

Visualization using vanilla SVG (part 3)

In the previous part, I've added the auxiliary elements to the charts so that they look like proper charts: axes, ticks, grid. In this part, I'm going to finish the non-interactive parts of the charts by adding weather condition and wind direction icons, and the legend.

The weather condition icons

I want to display a weather condition icon for each date below the x-axis line. I will use the same pattern as the tick marks, therefore the most logical place to patch this code in is the tick marks block.

As usual, I'm going to split out some parameters that I expect to tweak later.

{ // Draw tick marks and labels, and weather icons
    let /* .... */
        iconSize = 32, // px
        iconOffset = 4 // px

    // ....
}

I've fixed the comment that describes the block to include a reference to 'weather icons', so I can find the code easily later. I've defined the iconSize and iconOffset values, which represent the pixel dimensions of the icons within the chart and the distance between the date label and the icon, respectively.

The weather icons are going to be drawn alongside the horizontal ticks, so I'm updating that part of the code.

First I'm setting up some values that will hold for each iteration. While at it, I'm noticing there are y coordinates unnecessarily calculated each iteration, so I'm pulling them out of the loop. I'm also adding a y position for the icon.

{ // Horizontal ticks and weather icons
    let /* .... */
        tickY1 = paddingTop + plotHeight,
        tickY2 = paddingTop + plotHeight + tickLength,
        tickLabelY = paddingTop + plotHeight + tickLength + tickLabelGap,
        iconY = tickLabelY + 17 + iconOffset,
        // ....
        icons = svg.querySelectorAll('.weather-icon')

    for (let i = 0; i < numTickMarks; i++) {
        // ....
        tickMark.setAttribute('d', `M${tickX},${tickY1} L${tickX},${tickY2}`) // <-- fixed
        // ....
        tickLabel.setAttribute('y', tickY) // <-- fixed
    }
}

(Number 17 is the measured size of the label, hard-coded pending a better way to calculate it.)

Now on to drawing the icons.

{ // Horizontal ticks and weather icons
    // ....
    for (let i = 0; i < numTickMarks; i++) {
        // ....

        let iconId = getWeatherId(svg.weatherCodes[i]),
            icon = icons[i] ?? createSVGInside(svg, 'use', 'weather-icon')

        icon.setAttribute('href', `icons.svg#${iconId}`)
        icon.setAttribute('x', tickX)
        icon.setAttribute('y', iconY)
        icon.setAttribute('width', iconSize)
        icon.setAttribute('width', iconSize)
        icon.setAttribute('transform', `translate(-${iconSize / 2}, 0)`)
    }
}

There's one thing bugging me now, this:

icon.setAttribute('width', iconSize)
icon.setAttribute('width', iconSize)

These should be set once at creation, and not every update. The problem is createSVGInside() doesn't support setting width and height attributes. I have some options:

  1. Leave it as is. It's not a big deal.
  2. Add support for setting the width and height by taking more parameters.
  3. Add support for setting the width and height by making createSVGInside() more generic, and also disrupting the existing usages.

Given that the forecast API only returns up to 15 days, there's a physical limit to how demanding visualization is going to get for me. Not a lot. So leaving it as is a perfectly reasonable and pragmatic approach.

Going with the third option can potentially improve the code clarity (you'll see later), so it's a worthwhile endeavor. Making the code more generic has other benefits as well. Generic code tends to have less edge cases, for instance, as it captures a more complete relationship between input and output values. While the impact is not going to be as pronounced in this case, you'll notice the difference.

I don't have a much code right now, so updating existing usages is not a big trade-off. (I'd also like to emphasize that this is one of the reasons I avoid having too much code – it keeps the door open for these kinds of decisions.)

let /* .... */
    createSVGInside = (parentNode, tagName, attributes) => {
        let element = createSVG(tagName)
        for (let k in attributes) element.setAttribute(k, attributes[k])
        parentNode.append(element)
        return element
    }

I was previously accepting the class attribute as the third parameter. Now I'm taking the attributes parameter which is an object. Since I iterate over the attributes, I no longer need to test whether class is specified – or even whether anything is passed – the beautiful thing about the for..in loop in JavaScript is that it will not blow up if attributes is undefined or null, and I don't need to handle those cases explicitly. (I wish they kept that design for the for..of loop. Two steps forward, one step back, imho.)

With this, I can now do:

{ // Horizontal ticks and weather icons
    // ....
    for (let i = 0; i < numTickMarks; i++) {
        // ....

        let iconId = getWeatherId(svg.weatherCodes[i]),
            icon = icons[i] ?? createSVGInside(svg, 'use', {
                class: 'weather-icon', 
                width: iconSize, 
                height: iconSize,
            })

        // ....
    }
}

The result looks like this:

Screenshot of the temperature chart with weather
icons
Now I can see the exact weather for each day without flipping back to the table view. 💪

Wind direction

The wind direction icons are the little arrow icons that appear in the table view in the wind column. These are different from the weather icons in two ways:

  1. They are only used in the wind chart.
  2. They are drawn at the same spot as data points in the plot.

Running the same code for every chart would be wasteful when only wind chart requires it. I therefore have two options that I can think of:

  1. Create a separate function that adds the wind icons to the chart.
  2. Add an optional mechanism to the existing drawChart() function.

Each option has its merits.

The first option has the merit of being isolated from drawChart(). It may cater better to the specific needs I might have. It's a safer choice in the sense that it doesn't require full awareness of what drawChart() is doing.

The second option is theoretically more efficient as drawing of the wind direction icons would piggyback on the iteration to draw the time series. More importantly, it would reuse the x and y axis calculations, which I won't need to extract.

I'll go with the second option, prioritizing cohesion of code related to plotting the series.

First I want to clean up the code a bit to prepare it for the main change. Reading it again, I see parts that could've been a bit better organized.

{ // Plot the series
    let /* .... */
        stepX = plotWidth / NUM_SEGMENTS,
        rangeYScale = plotHeight / (extent.max - extent.min),
        domainToRangeX = x => paddingLeft + x * stepX,
        domainToRangeY = y => paddingTop + plotHeight - Math.round((y - extent.min) * rangeYScale)

    for (let i = 0; i < timeSeriesList.length; i++) {
        let /* .... */
            x = domainToRangeX(0),
            y = domainToRangeY(ys[0]),
            d = `M${x},${y}`

        for (let i = 1; i < ys.length; i++) {
            x = domainToRangeX(i)
            y = domainToRangeY(ys[i])
            d += ` L${x},${y}`
        }

        // ....
    }
}

I've taken the domainToRangeY() outside the for loop as it does not rely on any values inside the loop other than y, which it receives as a parameter. I've also defined variables x and y to make the d attribute values a bit clearer. They were a bit cluttered with inline calculations.

Now I can insert calls to an optional function that will be invoked for every data point in the series.

{ // Plot the series
    // ....

    for (let i = 0; i < timeSeriesList.length; i++) {
        // ....

        svg.onpoint?.(0, x, y)

        for (let i = 1; i < ys.length; i++) {
            svg.onpoint?.(i, x, y)
            d += ` L${x},${y}`
        }

        // ....
    }
}

The function takes the index, and x and y coordinates. I will use the index to get the the wind direction data, and the coordinates to position the icon. The code that adds the icons is going to live in the displayForecast() function.

{ // Draw wind direction icons
    let iconSize = 24,
        iconBackgroundSize = 14,
        iconOffset = iconSize / 2

    windChart.onpoint = (i, x, y) => {
        let icons = windChart.querySelectorAll('.wind-direction'),
            iconGroup = icons[i] || createSVGInside(windChart, 'g', {
                class: 'wind-direction',
                width: iconSize,
                height: iconSize,
            }),
            background = iconGroup.querySelector('circle') || createSVGInside(iconGroup, 'circle', {
                cx: iconOffset,
                cy: iconOffset,
                r: iconBackgroundSize,
            }),
            icon = iconGroup.querySelector('use') || createSVGInside(iconGroup, 'use', {
                width: iconSize,
                height: iconSize,
                href: 'icons.svg#wind-direction',
            }),
            windDirection = data.daily.wind_direction_10m_dominant[i + 1]

        iconGroup.setAttribute('transform', `
            rotate(${windDirection}, ${x}, ${y}) 
            translate(${x - iconOffset}, ${y - iconOffset}) 
        `)
    }
}
drawChart(windChart)

Let me walk you through it.

I create a block around the code so it doesn't leak variables. Namely, the following:

let iconSize = 24,
    iconBackgroundSize = 14,
    iconOffset = iconSize / 2

Again, tweakable variables that I figured I might need to mess with later. The background size refers to the radius of a circle within which the icon is drawn.

I add the function that draws the icon:

windChart.onpoint = (i, x, y) => {
    // ....
}

Within the function, I select all existing icons, and then reuse or create a group that will hold the icon and the background.

let icons = windChart.querySelectorAll('.wind-direction'),
    iconGroup = icons[i] || createSVGInside(windChart, 'g', {
        class: 'wind-direction',
        width: iconSize,
        height: iconSize,
    }),
    background = iconGroup.querySelector('circle') || createSVGInside(iconGroup, 'circle', {
        cx: iconOffset,
        cy: iconOffset,
        r: iconBackgroundSize,
    }),
    icon = iconGroup.querySelector('use') || createSVGInside(iconGroup, 'use', {
        width: iconSize,
        height: iconSize,
        href: 'icons.svg#wind-direction',
    }),
    windDirection = data.daily.wind_direction_10m_dominant[i + 1]

I've retrieved the wind direction value and stored it in a variable for later usage.

Lastly, I'm updating the position of the group according to the data.

iconGroup.setAttribute('transform', `
    rotate(${windDirection}, ${x}, ${y}) 
    translate(${x - iconOffset}, ${y - iconOffset}) 
`)

In short, the two transforms will rotate the icon to point in the same direction as the wind, and move it to the (x, y) coordinates, but such that the group is centered over it.

The CSS looks like this:

x-weather-forecast .chart .wind-direction circle {
    stroke: var(--chart-wind-color);
    fill: var(--background-color);
}

x-weather-forecast .chart .wind-direction use {
    --icon-stroke: var(--chart-wind-color);
}

Here's what it all looks like:

Screenshot of the wind chart with direction
icons
The icons add interest to the overall visual experience, and it conveys important information.

Legend

The legend does not need to be placed inside the <svg> element.

  1. It's just a look-up table for the colors used in the chart.
  2. It doesn't have to precisely align with anything in the chart.
  3. It doesn't rely on any information that is only available during the drawing of the chart.

For these three reasons, I'm better off doing the legend outside the <svg> element – in plain HTML and CSS.

Since these charts have predetermined time series, I can hard-code the legend in the <figcaption> element.

<figcaption>
    <span class="alt-text">Temperature chart</span>
    <ul class="legend">
        <li>Maximum temperature</li>
        <li>Minimum temperature</li>
    </ul>
</figcaption>

The color isn't indicated in HTML. That's going to be done in the CSS. On the other hand, I'm now noticing I don't have viable hooks to select specific <li> elements. For that, I'll need to lift the chat-specific classes like .temperature-chart from the <svg> element onto the <figure> element, which is the common ancestor.

<figure class="temperature-chart">
    <svg class="chart" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"></svg>
    <!-- .... -->
</figure>

In this example, I've moved the .temperature-chart class from the <svg> element to the <figure> element, which now allows me to select the legend <li> items specific to this chart. I need to fix the JavaScript to reflect this:

let /* .... */
    temperatureChart = this.querySelector('.temperature-chart .chart'),
    precipitationChart = this.querySelector('.precipitation-chart .chart'),
    windChart = this.querySelector('.wind-chart .chart')

I've changed the .temperature-chart selector to .temperature-chart .chart.

Although HTML and JavaScript are loosely coupled, they are still coupled. The coupling is through custom elements and selectors. Therefore, if you make a change that would require a change in the selector, you obviously will need to edit in two places rather than just one.

Although my approach generally aims to minimize the times when I have to edit in two places, there's an even more systematic approach that was conveyed to me by Eugene Baziak: use classes as selectors in CSS, use data attributes as selectors in JavaScript. For instance, in this particular case, I'd have a data-id="temperature-chart" or something on the <svg> element, and select it in JavaScript using [data-id=temperature-chart], and I wouldn't need to change JavaScript in order to move that class. In this blog series, I specifically avoided this superior approach because I thought it may look a bit weird to people who are not used to exploiting the full range of selectors. I admit I have likely overthought this. As a result, though, I now have a good opportunity to show you what problem Eugene's approach solves, so it wasn't all bad. Moving on...

Next, I'm doing the styling for the legend. The base style looks like this:

x-weather-forecast .legend {
    display: flex;
    flex-wrap: wrap;
    gap: 1em;
    padding: 1em;
}

x-weather-forecast .legend li {
    display: flex;
    gap: 0.2em;
    align-items: center;
    font-size: 87.5%;
    text-transform: lowercase;
}

x-weather-forecast .legend li::before {
    content: '';
    display: inline-block;
    vertical-align: middle;
    width: 0.875em;
    aspect-ratio: 1;
    border-radius: 50%;
    background: var(--color);
}

It sets the layout, font size, and other common properties, and it leaves the --color custom property for me to specify the colors.

In order to be able to reuse the custom properties for colors, I need to lift them up first. They are currently scoped to .chart (the <svg> elements).

x-weather-forecast {
    /* .... */
    --chart-temperature-min-color: rgb(119, 163, 245);
    --chart-temperature-max-color: rgb(229, 30, 30);
    --chart-precipitation-color: rgb(4, 156, 173);
    --chart-wind-color: rgb(36, 188, 74);
    /* .... */
}

@media (prefers-color-scheme: light) {
    x-weather-forecast {
        --chart-temperature-min-color: rgb(18, 64, 149);
        --chart-temperature-max-color: rgb(138, 5, 5);
        --chart-precipitation-color: rgb(4, 89, 99);
        --chart-wind-color: rgb(10, 89, 30);
        --chart-grid-line-color: #999;
    }
}

x-weather-forecast .chart {
    aspect-ratio: 2;
}

The aspect-ratio property remains with the .chart element.

Now I can apply the colors to the legend items:

x-weather-forecast .temperature-chart .legend li:first-child {
    --color: var(--chart-temperature-max-color);
}

x-weather-forecast .temperature-chart .legend li:last-child {
    --color: var(--chart-temperature-min-color);
}

x-weather-forecast .precipitation-chart .legend li:first-child {
    --color: var(--chart-precipitation-color);
}

x-weather-forecast .wind-chart .legend li:first-child {
    --color: var(--chart-wind-color);
}

x-weather-forecast .wind-chart .legend li:last-child x-icon {
    --icon-stroke: var(--chart-wind-color);
}

x-weather-forecast .wind-chart .legend li:last-child::before {
    display: none;
}

The last block is needed because the last legend item in the wind chart is for the wind icon, and it doesn't need the circle that represents the color.

The result looks like this:

Screenshot of a chart with the legend
The legend is complete and it also doubles as the description of the chart.

Conclusion

With this part, I conclude the work on the visualization. In the intro, I used the phrase 'non-interactive' suggesting that I might do the chart interaction next. But after three parts, I'm feeling the need to move on to another topic. Therefore, in the next part, I'll be tackling offline support and PWA conversion. I'll come back to the charts later.

Check out the live version to see the current state in action.

Posted in Programming tips
Back to top