Weather App - Charts with D3.js

Published: Apr 28, 2023
react
d3
axios
framer motion
styled components

The aim of this project was to work with the charting library D3.js as it is designed to handle lots of data very efficiently and very quickly. Marry that up with React and it's ability to manage state, component and props and you have a really powerful combination of tools.

I use Axios to bring in the data from the weather API https://open-meteo.com/, and I use Framer Motion to animate the charts and other UI elements.

Visit Website

Here is the mobile design for the Weather website I have created and some of its key features:

Weather-whole-page.jpg
  • Todays weather summary, including the location, temperature, wind speed and direction
  • A select box to allow you to change locations around the world
  • 7 days of weather are displayed with a button for each day, upon clicking gives a summary of that days weather
  • Interactive hourly line charts showing the temperature, humidity and rain chance for that day and a summary beneath it
  • You can hover over the line chart for pointing devices, or touch the chart to see the hourly data
  • A couple of donut charts for quick visual reference, showing the humidity and rain chance
  • The sunrise and sunset times

Let's dive in...

Starting with App, I have 4 key pieces of state used throughout the website:

src\App.js

const [selectedDay, setSelectedDay] = useState(todaysDate)
const [weatherData, setWeatherData] = useState()
const [weatherDayData, setWeatherDayData] = useState()
const [weatherWeekData, setWeatherWeekData] = useState()
const [currentLocation, setCurrentLocation] = useState(locations[0])

These values are passed down in to the 4 primary components:

return (
  <>
    {weatherData && (
      <CurrentDayInfo
        key={`CurrentDayInfo${selectedDay}`}
        data={weatherData.data}
        locations={locations}
        currentLocation={currentLocation}
        setCurrentLocation={setCurrentLocation}
      />
    )}
    {weatherWeekData && (
      <DayPicker
        key={`DayPicker${selectedDay}`}
        weekData={weatherWeekData.data}
        setWeatherData={setWeatherData}
        setSelectedDay={setSelectedDay}
        currentLocation={currentLocation}
      />
    )}
    {weatherDayData && (
      <LineChart
        key={`LineChart${selectedDay}`}
        data={weatherDayData.data}
      />
    )}
    {weatherDayData && (
      <SelectedDayInfo
        key={`SelectedDayInfo${selectedDay}`}
        dayData={weatherDayData.data}
      />
    )}
  </>
);
  • <CurrentDayInfo /> displays today's weather information and a select box to allow you to change the currentLocation peice of state. When this happens, the weather API is called to get the weather data for the new location.
  • <DayPicker /> shows 7 buttons for one week of forecasted weather. When the user picks a day, the weather API is called to get the chosen day's data.
  • <LineChart /> is where all the D3 chart work begins. This doesn't modify any of the state, it just receives data.
  • <SelectedDayInfo /> displays a couple of pie charts and the sunrise and sunset values. This doesn't modify any of the state, it just receives data.

I have a useEffect, which listens to currentLocation and selectedDay for changes before calling functions which query the weather API:

useEffect(() => {
	getWeatherData(new Date(), setWeatherData, setWeatherWeekData, currentLocation)
	getWeatherDayData(selectedDay, setWeatherDayData, currentLocation)
}, [currentLocation, selectedDay])

These functions are pretty straight forward Axios calls, setting the aforementioned state once they receive data:

export const getWeatherDayData = (date, setWeatherDayData, currentLocation) => {
    axios.get(`https://api.open-meteo.com/v1/forecast?latitude=${currentLocation.latitude}&longitude=${currentLocation.longitude}&hourly=temperature_2m,relativehumidity_2m,precipitation_probability,rain&daily=weathercode,temperature_2m_max,temperature_2m_min,sunrise,sunset,uv_index_max,precipitation_probability_max&current_weather=true&forecast_days=1&start_date=${date}&end_date=${date}&timezone=auto`)
    .then(function (response) {
        setWeatherDayData(response)
    })
    .catch(function (error) {
        console.log(error);
    })
}

The API URL is a template literal with the currentLocation latitude, longitude values and the date passed to it.

Let's skip to some D3 code

D3 gives you a lot of control over how its charts are presented via DOM manipulation/creation and CSS. It has many built-in functions to handle pie, line and bar charts to name a few. It will handle creating things like the X and Y axis, calculating minimum and maximum values whilst scaling this to fit the dimensions of your chart.

I'll skip over <CurrentDayInfo /> and <DayPicker /> to the <LineChart /> component, so we can get straight to talking about the focus of this article, D3. I'll talk about some of their highlights later. Suffice to say both of those components trigger updates to the weather data that this component will handle.

src\components\LineChart.js

import { useState, useRef, useEffect } from 'react'
import * as d3 from "d3";
import { LinePath } from './LinePath';
import { arrayOfWeatherAndTimeObjs } from '../utilities/formatWeatherData';

const ChartInner = ({ data }) => {
  const { hourly } = data
  const { temperature_2m, relativehumidity_2m, precipitation_probability, time } = hourly

  const [isTablet, setIsTablet] = useState(true)

  const svgOpt = {
    width: 1040,
    innerWidth: undefined,
    height: 270,
    margin: {
      top: 50,
      right: 20,
      bottom: 120,
      left: 30,
    },
    strokecolor: ["white", "#33eb33", "yellow", "#b4e6ff"],
    fillcolor: ["white", "#33eb33", "yellow", "#b4e6ff"],
    chartCircleSize: isTablet ? "6" : "4",
    chartCircleSizeActive: isTablet ? "20" : "9",
  }

  svgOpt.innerWidth = svgOpt.width - svgOpt.margin.left - svgOpt.margin.right

  ...

A few effects are imported from React, D3 is imported, LinePath is a component that draws the line graph and arrayOfWeatherAndTimeObjs is a function to format the weather data in the way LinePath needs it.

The component begins receiving data as the only prop. This is the weather API data for the selectedDay. A few values are destructured from this, namely: hourly, then temperature_2m, relativehumidity_2m, precipitation_probability and time.

I have isTablet state, which acts like a device check to allow me to apply settings if the user is using a mobile or not.

Then I have an object with various SVG configuration settings. These help me draw the chart and calculate various offsets, circle sizes and colours.

// Chart Refs:

const temperatureRef = useRef(null);
const humidityRef = useRef(null);
const pChanceRef = useRef(null);
const hoverRectanglesRef = useRef(null);
const lineChartSVGRef = useRef(null);

// Weather Charts Line Data:

const hourlyTempData = arrayOfWeatherAndTimeObjs(temperature_2m, time, 'temperature')
const hourlyHumidityData = arrayOfWeatherAndTimeObjs(relativehumidity_2m, time, 'humidity')
const hourlyPChanceData = arrayOfWeatherAndTimeObjs(precipitation_probability, time, 'pChance')

const chartXScale = d3
  .scaleLinear()
  .domain([hourlyTempData[0].date, hourlyTempData.at(-1).date])
  .range([svgOpt.margin.left, svgOpt.width - svgOpt.margin.right])

// Define Charts to show:

const lineCharts = [
  { id: "temperature", data: temperature_2m, chartXScale, ref: temperatureRef },
  { id: "humidity", data: relativehumidity_2m, chartXScale, ref: humidityRef },
  { id: "pChance", data: precipitation_probability, chartXScale, ref: pChanceRef },
]

I continue on to set up some refs that I use later to set up some hover and touch events which I'll go into detail later.

I then set up some variables to store each of the weather types: hourlyTempData, hourlyHumidityData and hourlyPChanceData. This is done by passing the destructured weather data to my arrayOfWeatherAndTimeObjs function returning an array of objects like this:

[
  {date: Sun Apr 30 2023 00:00:00 GMT+0100 (British Summer Time), temperature: 0},
  {date: Sun Apr 30 2023 01:00:00 GMT+0100 (British Summer Time), temperature: 0},
  {date: Sun Apr 30 2023 02:00:00 GMT+0100 (British Summer Time), temperature: 0},
  (...for every hour in the day)
]

Now I have this data setup, it's ready to pass to D3 so it can create the line charts X axis scale. The charts X axis has an entry for each hour of the day:

const chartXScale = d3
  .scaleLinear()
  .domain([hourlyTempData[0].date, hourlyTempData.at(-1).date])
  .range([svgOpt.margin.left, svgOpt.width - svgOpt.margin.right]);

scaleLinear is a D3 function that takes a domain and range.

domain takes two values, establishing the minimum and maximum values of the data. In this case, it's hours of the day, so 0 (or midnight) would be the first value and the maximum value would be 23. I use a handy array.at(-1) function to get the last item in the array.

range takes two values, establishing the size of the space to map the values from the domain. So in this case it's the width of the SVG and I've used the svgOpt margin values to trim the chart a little.

When a value is passed to chartXScale, D3 can calculate where that value would be plotted on the chart. All I will be passing to it will be an hour in the day.

The charts are nearly ready to go, I just need to create an array for them so I can .map over this later to display the 3 charts:

const lineCharts = [
  { id: "temperature", data: temperature_2m, chartXScale, ref: temperatureRef },
  { id: "humidity", data: relativehumidity_2m, chartXScale, ref: humidityRef },
  { id: "pChance", data: precipitation_probability, chartXScale, ref: pChanceRef },
]

Here is the return values for the <LineChart /> component.

return (
  &lt;LineChartWrapper&gt;
    &lt;svg
      ref={lineChartSVGRef}
      viewBox={`0 0 ${svgOpt.width} ${svgOpt.height}`}
    &gt;
      {lineCharts.map((chart, index) =&gt; (
        &lt;LinePath
          key={chart.id}
          svgOpt={svgOpt}
          time={time}
          data={chart.data}
          count={index}
          refValue={chart.ref}
          chartXScale={chart.chartXScale}
        /&gt;
      ))}
      &lt;g&gt;
        {hourlyTempData.map((time, index) =&gt; {
          return (
            &lt;text
              key={`${index}hourTxt`}
              x={chartXScale(time.date)}
              y={svgOpt.height - 20}
              fill={svgOpt.fillcolor[0]}
              textAnchor=&quot;middle&quot;
            &gt;
              {hourlyTempData[index].date.getHours()}
            &lt;/text&gt;
          )
        })}
      &lt;/g&gt;
    &lt;/svg&gt;
  &lt;/LineChartWrapper&gt;
)

Drawing the path for the line

I have created a <LinePath /> component to isolate drawing a line chart, so I can easily recycle and reuse this component, passing it the data it needs whenever I want a new line path for the chart. Hooray React!

src\components\LinePath.js

import * as d3 from &quot;d3&quot;;
import { motion } from &quot;framer-motion&quot;;
import { arrayOfWeatherAndTimeObjs } from &quot;../utilities/formatWeatherData&quot;;

export const LinePath = ({
  svgOpt,
  time,
  data,
  count,
  refValue,
  chartXScale,
}) =&gt; {
  const weatherData = arrayOfWeatherAndTimeObjs(data, time, &quot;weatherValue&quot;);

  const chartYScale = d3
    .scaleLinear()
    .domain(d3.extent(weatherData.map((item) =&gt; item.weatherValue)))
    .range([svgOpt.height - svgOpt.margin.bottom, svgOpt.margin.top]);

  const line = d3
    .line()
    .x((item) =&gt; chartXScale(item.date))
    .y((item) =&gt; chartYScale(item.weatherValue));

  const lineData = line(weatherData);

  return (
    &lt;g&gt;
      &lt;motion.path
        initial={{ pathLength: 0 }}
        animate={{ pathLength: 1 }}
        transition={{ duration: 1 }}
        d={lineData}
        fill=&quot;none&quot;
        stroke={svgOpt.strokecolor[count]}
        strokeWidth=&quot;2&quot;
      /&gt;
      &lt;g ref={refValue}&gt;
        {weatherData.map((time, index) =&gt; (
          &lt;motion.circle
            key={index + time}
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            transition={{ duration: 0.15, delay: 0.05 * index }}
            r={svgOpt.chartCircleSize}
            cx={chartXScale(time.date)}
            cy={chartYScale(time.weatherValue)}
            fill={svgOpt.fillcolor[count]}
          /&gt;
        ))}
      &lt;/g&gt;
    &lt;/g&gt;
  );
};

Quite a few props are passed in if you remember from the <LineChart /> component.

I use data and time in the arrayOfWeatherAndTimeObjs function we saw earlier to format an array of objects which I store in weatherData.

I then establish a chartYScale using the scaleLinear function, domain and range in the same way we established the chartXScale. One of the differences this time is for the domain, I use D3s extent function which returns two values and figures out the min and max values from my weatherData, thanks again D3 for doing all this calculating for me.

Then I create a line variable that takes a D3 line function to prepare it for receiving data to plot a line. The line function takes x and y values, looping over the data it receives and passing it to the chartXScale or chartYScale respectively.

Then I create the lineData, passing the weatherData to the line function to generate the path data, which looks like:

M30,150L73.043,150L116.087,150L159.13,150L202.174,150L245.217,150L288.261,150L331.304,150L374.348,150L417.391,150L460.435,150L503.478,150L546.522,150L589.565,150L632.609,150L675.652,150L718.696,150L761.739,116.667L804.783,83.333L847.826,50L890.87,83.333L933.913,116.667L976.957,150L1020,150

This is basically svg language for drawing a path. Now we have this, we can pass it to the <path> element, via the d attribute that we declare in the return.

Right now, there's just a line drawn for our data. But, I want to add a small circle at each plotted point to improve the readability of the chart for the user.

This is the final part of this component, and I map over the weatherData again, returning an svg <circle> element. This is placed using the cx and cy attributes with the values from the chartXScale or chartYScale functions respectively.

Framer Motion

If you're wondering what the motion.path and motion.circle are, that's Framer Motion and how you attach it to elements and define animation. The initial attribute sets how the element's animation begins, in this case with an opacity of 0. The animate attribute sets how the element animation finishes, becoming visible with an opacity of 1. And the transition attribute sets the animation behaviour, allowing you to configure things like duration and delay.

Enhancing the chart: displaying the hour by hour information

I realised on mobile, the chart alone didn't describe the hourly values and since it was much smaller than desktop, you couldn't really see the chart very well. That's why for mobile I added the temperature, humidity, rain chance values beneath the chart.

For desktop, I re-used the same component, refashioned into a tooltip that follows your mouse around when you hover over the chart.

On mobile I'm quite pleased I managed to get the chart to update the details as you dragged over it with your finger instead of only having to tap each hour.

I didn't talk about this code earlier as I wanted to keep the example code as clearly focussed on how I used D3 to draw the charts and not cloud the explanation with all sorts of other features I added later to improve the overall experience.

Following is the code I added in each component to achieve this:

src\components\LineChart.js

const [toolTipVisible, setToolTipVisible] = useState(isTablet ? true : false)
const [toolTipHour, setToolTipHour] = useState()
const [toolTipTemp, setToolTipTemp] = useState()
const [toolTipHumidity, setToolTipHumidity] = useState()
const [toolTipRainChance, setToolTipRainChance] = useState()
const [toolTipX, setToolTipX] = useState()
const [toolTipY, setToolTipY] = useState()

let hoverRectangleLocations = [0]
const [touchedRectanglePrevious, setTouchedRectanglePrevious] = useState(0)

toolTipVisible is used to track if the tooltip is visible or not. This is for desktop and not mobile since the tooltip component is always visible on mobile.

toolTipHour, toolTipTemp and toolTipHumidity, toolTipRainChance store the hovered, or touched values on the chart.

toolTipX and toolTipY store the mouse position on desktop so that the tooltip display follows the users mouse around the chart.

hoverRectangleLocations is an array which later populates with the x position of a bunch of svg rectangles used to detect either hover or touch.

touchedRectanglePrevious stores state for when the selected hour on the chart has changed and I need to reset the plotted circle back to its original size.

Let's look at the changes made to the <svg> element which displays the chart:

&lt;svg
  ref={lineChartSVGRef}
  viewBox={`0 0 ${svgOpt.width} ${svgOpt.height}`}
  onMouseOut={() =&gt; !isTablet &amp;&amp; setToolTipVisible(false)}
  onMouseMove={(e) =&gt; moveToolTip(e)}
  onTouchMove={(e) =&gt; touchOver(e)}
&gt;

The onMouseOut listener is setting some state which will hide the tooltip if the user is on desktop.

The onMouseMove listener will send the mouse event to a moveToolTip function.

And the onTouchMove listener will pretty much do the same as the onMouseMove, except it will send the touch event to the touchOver function.

Here is the moveTooltip function:

const moveToolTip = (e) => {
  const svgLeftPos = lineChartSVGRef.current.getBoundingClientRect().left;
  const mouseToSvgOffset = e.clientX - svgLeftPos;
  if (mouseToSvgOffset < 320) setToolTipX(e.clientX + 70);
  else setToolTipX(e.clientX - 280);
  setToolTipY(e.clientY - 50);
}

I save the horizontal starting position of where the chart begins to the svgLeftPos. This is then used to calculate the offset from where the mouse is using the e.clientX.

I do a quick check to make sure the mouse isn't too close to the left hand side and if it is, I want to shift the tooltip over to the right hand side of the mouse so it doesn't disappear off screen. Otherwise, the tooltip sits on the left side of the mouse by default.

Here is the touchOver function:

const touchOver = (e) => {
  resetChartCircles(touchedRectanglePrevious)
  const touchX = e.touches[0].clientX - svgOpt.margin.left
  const touchedRectangle = hoverRectangleLocations.findIndex(
    (item) => item > touchX
  )
  if (touchedRectangle >= 0) {
    setTouchedRectanglePrevious(touchedRectangle)
    setHourOnChart(touchedRectangle)
  }
}

Here the user is on mobile and tapping, or swiping across the chart.

First, I call the resetChartCircles function which I'll explain later, but it will reset the previously selected circle back to its default size.

Then much like the moveTooltip function I calculate where the user touched the screen on the x axis (horizontally). This would return a value like 385 for example, which is the number of pixels from the left hand side.

This value is then checked against the hoverRectangleLocations array to find the index of where that value is greater than each of the values in the array. This tells me which hour they have either tapped on, or swiped over.

I then set some state to store that value as the previously selected hour and then call the setHourOnChart function which looks like this:

const setHourOnChart = (hour) => {
  setTouchedRectanglePrevious(hour)
  resetChartCircles(touchedRectanglePrevious)
  setToolTipVisible(true)
  setToolTipHour(hour)
  setToolTipTemp(hourlyTempData[hour].temperature)
  setToolTipHumidity(hourlyHumidityData[hour].humidity)
  setToolTipRainChance(hourlyPChanceData[hour].pChance)
  lineCharts.forEach((chart) => {
    const target = chart.ref.current
    target.children[hour].setAttribute("r", svgOpt.chartCircleSizeActive)
  })
}

This function updates all of the state values mentioned earlier which will update the display for the user with all the weather values.

To indicate to the user which hour they are either hovered over, tapped or swiped over, I finish this function with a loop over all the line charts, changing the selected circle to a larger size so the user knows that's the hour they've selected.

To do this I had to update the <svg> element, where before I mapped over the hourlyTempData to output the <text> element and show the hour. I have now added a <rect> element (svg rectangle element) to detect the mouse events.

Here is that modified code:

&lt;g ref={hoverRectanglesRef}&gt;
  {hourlyTempData.map((time, index) =&gt; {
    return (
      &lt;g key={`${index}hoverRectangle`}&gt;
        {/* Rectangle to catch hover event for tooltip */}
        &lt;rect
          width={hoverRectangleWidth}
          height=&quot;100%&quot;
          x={chartXScale(time.date) - hoverRectangleWidth / 2}
          y=&quot;0&quot;
          fill=&quot;#ffffff00&quot;
          onMouseOver={() =&gt; mouseOverHour(index)}
          onMouseOut={() =&gt; mouseOutHour(index)}
        /&gt;
        {/* Hour number on X axis */}
        &lt;text
          x={chartXScale(time.date)}
          y={svgOpt.height - 20}
          fill={svgOpt.fillcolor[0]}
          textAnchor=&quot;middle&quot;
        &gt;
          {hourlyTempData[index].date.getHours()}
        &lt;/text&gt;
      &lt;/g&gt;
    );
  })}
&lt;/g&gt;;

This allows me to attach the onMouseOver and onMouseOut events on the <rect> element, passing them over to the mouseOverHour and mouseOutHour functions respectively:

const mouseOverHour = (hour) => setHourOnChart(hour);
const mouseOutHour = (hour) => !isTablet && resetChartCircles(hour);

Highlights from the other components

Selecting location:

The <CurrentDayInfo /> component has a html select field listing the locations around the world the user can select to view weather.

I have a locations object that looks like:

locations = [
  { value: "London", latitude: "51.50", longitude: "0.12"},
  { value: "Paris", latitude: "48.85", longitude: "2.35"},
  { value: "Rome", latitude: "41.90", longitude: "12.49"},
  ...
]

Which the component maps over, returning the select field options:

&lt;LocationSelect value={currentLocation.value} onChange={handleSelect}&gt;
  {locations.map((location) =&gt; (
    &lt;option key={location.value} value={location.value}&gt;
      {location.value}
    &lt;/option&gt;
  ))}
&lt;/LocationSelect&gt;;

The currentLocation is a React useState value passed in from App.js. It is here where this value is set when the user picks a different location.

I felt having to click/tap the select, then click/tap the location felt a bit laborious and instead added previous and next buttons that would simply move the select field forwards and backwards through its options.

Displaying an image and description for the weather type

The weather API gave me a weatherCode in the returned data which was simply a numerical value.

So I created a mapping for these numbers to return an object containing an image and text description:

import png_sunny from &quot;../images/sunny.png&quot;
import png_sunnyToCloudy from &quot;../images/sunny to cloudy.png&quot;
import png_fog from &quot;../images/fog.png&quot;
import png_showers from &quot;../images/showers.png&quot;
...(other images)

const text_clearSky = &quot;Clear sky&quot;
const text_mainlyClear = &quot;Mainly clear, partly cloudy, and overcast&quot;
const text_fog = &quot;Fog and depositing rime fog&quot;
const text_drizzle = &quot;Drizzle: Light, moderate, and dense intensity&quot;
...(other texts)

const img_clearSky = &lt;img src={png_sunny} alt={text_clearSky} /&gt;
const img_mainlyClear = &lt;img src={png_sunnyToCloudy} alt={text_mainlyClear} /&gt;
const img_fog = &lt;img src={png_fog} alt={text_fog} /&gt;
const img_drizzle = &lt;img src={png_showers} alt={text_drizzle} /&gt;
...(other image elements)

export const weatherCodes = {
  0: { image: img_clearSky, text: text_clearSky },
  1: { image: img_mainlyClear, text: text_mainlyClear },
  2: { image: img_mainlyClear, text: text_mainlyClear },
  3: { image: img_mainlyClear, text: text_mainlyClear },
  45: { image: img_fog, text: text_fog },
  48: { image: img_fog, text: text_fog },
  51: { image: img_drizzle, text: text_drizzle },
  ...(other mappings)
}

There were more mappings and quite a lot of duplicate values for weather codes, so doing it this way saved on the repetition.