Examples: time series

Author

James Goldie

Published

June 7, 2024

Let’s do something more useful: a time series of temperature extremes.

In Quarto, we’ll download the data for two cities (Melbourne and Brisbane), letting the user choose which to display. We’ll also create controls using Observable Inputs to let them choose a month and the extreme to display.

In climate parlance, the highest temperature of the day is called the “daily maximum temperature”, or tmax for short. The coldest temperature of the day is called “daily minimum temperature”, or tmin for short.

I’m just calling them “daytime temperature” and “nighttime temperature” here — although the lowest temperature can technically happen during the day, it’s usually at night!

Once the data has been appropriately filtered and calculated, we’ll pass it to our Svelte chart.

The chart expects a prop called data, which is an array of objects that each have a numerical year and a value.

You can! This chart can plot any series of data, although it expects a numerical x value called year (it doesn’t handle full dates) and a numerical y value called value.

The y-axis has a suffix that is set to "°C" by default, but you could change this to °F, kph or something else if you had some other kind of data!

Controls
Code
viewof selectedCity = Inputs.select(
  new Map([
    ["Melbourne", "086338"],
    ["Brisbane", "040842"]
  ]),
  {
    value: "086338"
  }
)

viewof selectedVariable = Inputs.select(
  new Map([
    ["Daytime", "tmax"],
    ["Nighttime", "tmin"]
  ]),
  {
    value: "tmax"
  }
)
Code
viewof selectedSeason = Inputs.select(
  new Map([
    ["Whole year", 0],
    ["January", 1],
    ["February", 2],
    ["March", 3],
    ["April", 4],
    ["May", 5],
    ["June", 6],
    ["July", 7],
    ["August", 8],
    ["September", 9],
    ["October", 10],
    ["November", 11],
    ["December", 12]
  ]),
  {
    value: 0
  }
)

viewof selectedMetric = Inputs.select(
  new Map([
    ["Hottest", "max"],
    ["Average", "mean"],
    ["Coldest", "min"],
  ]),
  {
    value: "Hottest"
  }
)

Now we’ll use Arquero to download and filter the selected data.

Code
import { aq, op } from "@uwdata/arquero"

fullCity = aq.loadCSV(selectedVariable + "." + selectedCity + ".daily.csv")

tidiedCity = fullCity
  .rename(aq.names("date", "value"))
  .filter(d => d.date !== null)
  .params({ selectedSeason: selectedSeason })
  .derive({
    year: d => op.year(d.date),
    month: d => op.month(d.date) + 1
  })

// filter unless "Whole year" is selected
filteredCity = selectedSeason == 0 ?
  tidiedCity :
  tidiedCity.filter(d => d.month == selectedSeason)

// now group by year and calculate the metrics
allMetrics = filteredCity
  .groupby("year")
  .rollup({
    min:  d => op.min(d.value),
    mean: d => op.mean(d.value),
    max:  d => op.max(d.value),
  })

// finally, select the year and whichever metric column is chosen by the user
finalData = allMetrics
  .select("year", selectedMetric)
  .rename(aq.names("year", "value"))

Now that the data’s processed, we’re ready to make the chart:

Try changing from Daytime to Nighttime!

Code
timeSeriesChart = new TimeSeriesChart.default({
  target: document.querySelector("#chart")
})

And there we go! And now to send our data to it:

This chart also takes an additional prop: colourScheme can be either cool or warm (cool is the default). Let’s set that depending on whether we’re looking at daytime or nighttime temperatures:

Use //| output: false when you update props so that they aren’t displayed underneath the cell.

Building the chart

Here’s the code in the Svelte file:

<script>
  import { blur } from "svelte/transition"
  import { extent } from "d3-array"
  import { scaleLinear, scaleSequential } from "d3-scale"
  import { interpolateYlGnBu, interpolateYlOrRd, select, axisLeft, axisBottom, format, tickFormat, formatLocale } from "d3"

  // should be an array of objects with:
  // year
  // value
  export let data = []
  export let valueSuffix = "°C"
  export let colourScheme = "cool" // or warm
  $: console.log(colourScheme)
  $: colourRamp = (colourScheme == "cool") ?
    interpolateYlGnBu :
    interpolateYlOrRd

  // dimensions bound to size of container
  let height = 500
  let width = 300

  // add padding to chart
  $: padX = [60, width - 10]
  $: padY = [height - 30, 10]

  $: xDomain = extent(data.map(d => d.year))
  $: yDomain = extent(data.map(d => d.value))

  // scales (flip the colours if they're cool)
  $: xScale = scaleLinear()
    .domain(xDomain)
    .range(padX)
  $: yScale = scaleLinear()
    .domain(yDomain)
    .range(padY)
  $: colourScale = scaleSequential()
    .domain(colourScheme == "cool" ? yDomain.reverse() : yDomain)
    .interpolator(colourRamp)

  // temperature formatter (for x-axis)
  const tempFormat = formatLocale({
    currency: ["", valueSuffix]
  });

  // axes
  let xAxisGroup
  let yAxisGroup
  $: select(xAxisGroup)
    .transition()
    .duration(500)
    .call(axisBottom(xScale).tickFormat(format(".0f")))
  $: select(yAxisGroup)
    .transition()
    .duration(500)
    .call(axisLeft(yScale).tickFormat(tempFormat.format("$.1f")))

</script>

<style>

  svg circle {
    transition:
      cx 0.5s ease-in-out,
      cy 0.5s ease-in-out,
      fill 0.5s ease-in-out;
  }

  #x-axis, #y-axis {
    font-family: system-ui, -apple-system;
    font-size: 14px;
  }


</style>

<main bind:clientHeight={height} bind:clientWidth={width}>
  <svg width={width} height={height}>

    <g>
      {#each data as { year, value } (year) }
      <!-- points go here-->
      <circle
        cx="{xScale(year)}px"
        cy="{yScale(value)}px"
        r="5"
        fill="{colourScale(value)}"
        in:blur={{ duration: 500 }}
        out:blur={{ duration: 500 }}
        >
      </circle>
      {/each}
    </g>
    <!-- trend line goes here -->
      
    <!-- axes goes here (is rendered imperatively above)-->
    <g bind:this={xAxisGroup} id="x-axis"
      style:transform="translateY({padY[0]}px)"
    />
    <g bind:this={yAxisGroup} id="y-axis"
      style:transform="translateX({padX[0]}px)"
    />
  </svg>
</main>

This chart has a few differences from the simpler barchart example:

  1. We make the chart responsive to changes in window size by wrapping the <svg> in a <main> and using bind:clientHeight and bind:clientWidth to track the space available to the chart
  2. Since this is designed for more data, we use axes instead of labelling each datum. We use d3-axis for this. Instead of directly drawing the SVG elements of the axis, we create a placeholder and then use D3’s functions to create the content, making them reactive to changes in data or window size with $:
  3. Instead of passing in colour directly, we let the user in Quarto choose a colour scheme ("warm" or "cool"). In this example, the colour scheme is connected to the Daytime or Nighttime option.

And here’s the processed data as a table, so we can see what we’re sending to Svelte. Note the two columns, year and value:

Code
Inputs.table(finalData)