3. The SDA library 🤓Visualizing data

Visualizing data with Simple Data Analysis and Plot

Data visualizations help you understand your data and communicate your findings. In this lesson, we’ll learn how to visualize SDA tables, from simple terminal charts to customized visualizations with the powerful Plot library.

We’ll use data on the 2023 Canadian wildfires and explore different visualization techniques, including line charts, beeswarm charts, and maps! 🌎

This lesson assumes you’ve already completed the Tabular data and Geospatial data lessons.

Want to know when new lessons are available? Subscribe to the newsletter ✉️ and give a ⭐ to the GitHub repository to keep me motivated! Click here to get in touch.

Setup

As in the previous lesson, create a new folder on your computer, open it with VS Code, and run the following command in the terminal: deno -A jsr:@nshiab/setup-sda

After running this command, your terminal will display a description of the created files and installed libraries.

Next, run the suggested task to start and watch sda/main.ts: deno task sda

A screenshot showing VS Code after running setup-sda.

💡

For SDA to work properly, it’s best to have at least version 2.1.9 of Deno. To check your version, you can run deno --version in your terminal. To upgrade it, simply run deno upgrade.

To keep our code organized, let’s create two functions:

  • crunchData, where we’ll fetch and clean the wildfire data.
  • visualizeData, where we’ll generate charts and maps.

Let’s start with crunchData. Create a crunchData.ts file in ./sda/helpers/. This async function expects a fires parameter of type SimpleTable, which is an SDA table. The function doesn’t need to return anything.

Inside, we’ll fetch and cache the wildfire data. This dataset is a slightly modified version of the one used in the previous lesson. It includes dates and only fires with a hectares burnt value greater than zero.

crunchData.ts
import { SimpleTable } from "@nshiab/simple-data-analysis";
 
export default async function crunchData(fires: SimpleTable) {
  await fires.cache(async () => {
    await fires.loadData(
      "https://raw.githubusercontent.com/nshiab/data-fetch-lesson/refs/heads/main/reported_fires_2023.csv",
    );
  });
}

Now, we can take care of visualizeData. Create a visualizeData.ts file in ./sda/helpers/. It’s also an async function that takes a fires parameter of type SimpleTable. For now, let’s just log the table.

visualizeData.ts
import { SimpleTable } from "@nshiab/simple-data-analysis";
 
export default async function visualizeData(fires: SimpleTable) {
  await fires.logTable();
}

Let’s update main.ts to create the fires table and pass it to our new functions.

main.ts
import { SimpleDB } from "@nshiab/simple-data-analysis";
import crunchData from "./helpers/crunchData.ts";
import visualizeData from "./helpers/visualizeData.ts";
 
const sdb = new SimpleDB();
const fires = sdb.newTable("fires");
 
await crunchData(fires);
await visualizeData(fires);
 
await sdb.done();

A screenshot showing VS Code after running helper functions.

Prepping the data

We have around 5,000 wildfires. Let’s say that we would like to visualize how much wildfires have burnt through the year, for each province. We need the cumulative sum of the area burnt.

In crunchData.ts, let’s sum up our fires per date and province.

crunchData.ts
import { SimpleTable } from "@nshiab/simple-data-analysis";
 
export default async function crunchData(fires: SimpleTable) {
  await fires.cache(async () => {
    await fires.loadData(
      "https://raw.githubusercontent.com/nshiab/data-fetch-lesson/refs/heads/main/reported_fires_2023.csv",
    );
  });
 
  await fires.summarize({
    values: "hectares",
    categories: ["province", "startdate"],
    summaries: "sum",
    decimals: 1,
  });
}

A screenshot showing VS Code logging data tables.

Now, we can compute the cumulative sum per province with the method accumulate. We can also round the values with the round method to avoid floating point errors.

crunchData.ts
import { SimpleTable } from "@nshiab/simple-data-analysis";
 
export default async function crunchData(fires: SimpleTable) {
  await fires.cache(async () => {
    await fires.loadData(
      "https://raw.githubusercontent.com/nshiab/data-fetch-lesson/refs/heads/main/reported_fires_2023.csv",
    );
  });
 
  await fires.summarize({
    values: "hectares",
    categories: ["province", "startdate"],
    summaries: "sum",
    decimals: 1,
  });
  await fires.accumulate("sum", "cumulativeHectares", {
    categories: "province",
  });
  await fires.round("cumulativeHectares", { decimals: 1 });
}

A screenshot showing a new column cumulativeHectares in a data table.

Let’s visualize this data now!

In the terminal

SDA has some methods to easily log charts in your terminal. It’s a great way to quickly inspect your data. Let’s work in visualizeData.ts.

In the example below, we use the logDotChart method with the startdate column for the x values and cumulativeHectares for the y values. I also added a few options:

  • We use the province values to create small multiples (one chart per province).
  • We make all the charts share the same scales, which is convenient for comparing provinces visually.
  • We format the y-axis labels with the formatNumber function from the journalism library. I created this library to provide general helper functions. It’s automatically installed when setting up with setup-sda.
visualizeData.ts
import { SimpleTable } from "@nshiab/simple-data-analysis";
import { formatNumber } from "@nshiab/journalism/web";
 
export default async function visualizeData(fires: SimpleTable) {
  await fires.logTable();
  await fires.logDotChart(
    "startdate",
    "cumulativeHectares",
    {
      smallMultiples: "province",
      fixedScales: true,
      formatY: (d) => formatNumber(d as number, { decimals: 0, suffix: " ha" }),
    },
  );
}

A screenshot showing dot charts in the terminal.

You are not limited to dot charts. You can also use the methods logLineChart, logBarChart, and logHistogram.

However, while these methods are very handy, they are quite limited.

With Plot

Cumulative hectares burnt per province

Plot is a fantastic library for creating charts. It’s based on the very famous d3 library (and built by the same team, including Mike Bostock and Philippe Rivière). However, it’s much easier to use because many things that need to be handled manually in d3 (scales, axes, etc.) are done automatically with Plot.

SDA integrates Plot seamlessly, and when you set up your project with setup-sda, it is automatically installed.

To create a chart and save it as a file (.jpeg, .png, or .svg if you want to rework it in Illustrator), you can use the writeChart method.

The writeChart method requires two arguments:

  • A function that draws a Plot chart.
  • A path for the image file that will be saved.

In visualizeData.ts, let’s create a simple line chart showing the area burnt throughout 2023 for each province.

visualizeData.ts
import { SimpleTable } from "@nshiab/simple-data-analysis";
import { line, plot } from "@observablehq/plot";
 
export default async function visualizeData(fires: SimpleTable) {
  await fires.logTable();
  const drawChart = (data: unknown[]) =>
    plot({
      marks: [
        line(data, {
          x: "startdate",
          y: "cumulativeHectares",
          stroke: "province",
        }),
      ],
    });
  await fires.writeChart(drawChart, "./sda/output/chart.png");
}

A screenshot showing a line chart saved as png.

💡

To open two tabs one on top of the other, right-click on the tab you want at the bottom and click on Split down. In the screenshot above, I toggled the terminal, but even if it’s not displayed, it’s still running and watching main.ts. So anytime I update the chart code, main.ts is rerun and the chart updates.

Let me explain what’s going on above:

  • On lines 6-15, I create an arrow function drawChart. The function expects an array of unknown values because it doesn’t know what’s in our table. In reality, our data will be an array of objects, as usual.
  • On line 7, I call the plot function from Plot. This function creates a chart and requires an object with some options.
  • On line 8, I add a marks key with an array. This is the most important option. marks define the shapes that represent our data.
  • On lines 9-13, inside the marks array, I call the line function to create lines based on our data. For the line options, I specify the x values (startdate), the y values (cumulativeHectares), and the stroke color (based on province values).
  • Finally, on line 16, I call the writeChart method on the fires table. The first argument is our drawChart function, which will automatically receive the fires data. The second argument specifies where we want the chart to be saved, which is in the output folder.

Our chart is quite basic for now, and there are some issues, like the y-axis. Let’s fix it!

visualizeData.ts
import { SimpleTable } from "@nshiab/simple-data-analysis";
import { line, plot } from "@observablehq/plot";
 
export default async function visualizeData(fires: SimpleTable) {
  await fires.logTable();
  const drawChart = (data: unknown[]) =>
    plot({
      inset: 10,
      y: {
        grid: true,
        label: "Area burned (hectares)",
        ticks: 5,
        tickFormat: (d) => `${d / 1_000_000}M`,
      },
      marks: [
        line(data, {
          x: "startdate",
          y: "cumulativeHectares",
          stroke: "province",
        }),
      ],
    });
  await fires.writeChart(drawChart, "./sda/output/chart.png");
}

A screenshot showing a chart with a better y axis.

This is better! To update the y-axis, you can pass options to y as an object. Here’s what we are doing in the code above:

  • We add grids to make the chart more readable.
  • We format the top axis label to be more understandable with the unit.
  • We limit the axis ticks to 5.
  • We format the tick labels to show millions of hectares instead of just hectares.

I also added an inset of 10 to give a little more space to the top label.

Now, let’s update the x label. There’s no need to modify the x tick labels because Plot handles dates well, automatically displaying years, months, days, or even time based on the scale values and axis width.

The colors are currently assigned automatically. We can choose a different scheme and maybe add a legend.

visualizeData.ts
import { SimpleTable } from "@nshiab/simple-data-analysis";
import { line, plot } from "@observablehq/plot";
 
export default async function visualizeData(fires: SimpleTable) {
  await fires.logTable();
  const drawChart = (data: unknown[]) =>
    plot({
      inset: 10,
      y: {
        grid: true,
        label: "Area burned (hectares)",
        ticks: 5,
        tickFormat: (d) => `${d / 1_000_000}M`,
      },
      x: {
        label: "Date",
      },
      color: {
        legend: true,
        scheme: "tableau10",
      },
      marks: [
        line(data, {
          x: "startdate",
          y: "cumulativeHectares",
          stroke: "province",
        }),
      ],
    });
  await fires.writeChart(drawChart, "./sda/output/chart.png");
}

A screenshot showing a chart with a color legend.

Hmmmm… The color legend doesn’t work very well with so many categories. We need to change our strategy. Instead of a color legend, maybe we could add a dot and a text label at the end of each line.

Let’s remove the color legend and add a dot mark. Since we want to add dots at the end of each line, we use the selectLast transform. This will select only the last data point.

Note that the order in the marks array is important. Marks are drawn in sequence, so if you want a shape to appear on top of another, add it later in the array.

visualizeData.ts
import { SimpleTable } from "@nshiab/simple-data-analysis";
import { dot, line, plot, selectLast } from "@observablehq/plot";
 
export default async function visualizeData(fires: SimpleTable) {
  await fires.logTable();
  const drawChart = (data: unknown[]) =>
    plot({
      inset: 10,
      y: {
        grid: true,
        label: "Area burned (hectares)",
        ticks: 5,
        tickFormat: (d) => `${d / 1_000_000}M`,
      },
      x: {
        label: "Date",
      },
      color: {
        scheme: "tableau10",
      },
      marks: [
        line(data, {
          x: "startdate",
          y: "cumulativeHectares",
          stroke: "province",
        }),
        dot(
          data,
          selectLast({
            x: "startdate",
            y: "cumulativeHectares",
            fill: "province",
          }),
        ),
      ],
    });
  await fires.writeChart(drawChart, "./sda/output/chart.png");
}

A screenshot showing a line chart with dots at the end.

And now let’s add the text labels. We use selectLast again with some additional options:

  • In addition to x and y, we also use z to let Plot know that we want a text label for each province. If we had used fill or stroke with province values, this would have been automatic, as the text would match the line colors. However, I prefer black text for labels.
  • We use the values in the province column for the text.
  • We apply a black fill with a white stroke to improve readability, ensuring the text remains visible even when overlapping other chart elements.
  • We set textAnchor to start so the text aligns with the dot, but we shift it by 5 pixels with dx to prevent it from touching the dot.
visualizeData.ts
import { SimpleTable } from "@nshiab/simple-data-analysis";
import { dot, line, plot, selectLast, text } from "@observablehq/plot";
 
export default async function visualizeData(fires: SimpleTable) {
  await fires.logTable();
  const drawChart = (data: unknown[]) =>
    plot({
      inset: 10,
      y: {
        grid: true,
        label: "Area burned (hectares)",
        ticks: 5,
        tickFormat: (d) => `${d / 1_000_000}M`,
      },
      x: {
        label: "Date",
      },
      color: {
        scheme: "tableau10",
      },
      marks: [
        line(data, {
          x: "startdate",
          y: "cumulativeHectares",
          stroke: "province",
        }),
        dot(
          data,
          selectLast({
            x: "startdate",
            y: "cumulativeHectares",
            fill: "province",
          }),
        ),
        text(
          data,
          selectLast({
            x: "startdate",
            y: "cumulativeHectares",
            z: "province",
            text: "province",
            fill: "black",
            stroke: "white",
            textAnchor: "start",
            dx: 5,
          }),
        ),
      ],
    });
  await fires.writeChart(drawChart, "./sda/output/chart.png");
}

A screenshot showing a line chart with dots and text labels at the end.

This is much better! But our chart needs some obvious adjustments now.

For example, we can see that the dots and labels at the end of the lines are not aligned… Since we summarized data based on the fires’ startDate, some provinces have gaps in their dates.

We also notice that not much is happening before mid-April and after October 1st.

Let’s go back to crunchData.ts to fix that:

  • On line 10, we filter the fires to keep only those with a startDate between April 15, 2023, and October 1st, 2023.
  • On lines 21-30, we retrieve the maximum cumulativeHectares for each province and store the results in a new table, maxValuesPerProvince. Then, we remove and rename columns to match those in the fires table. We also add a new column with the final date for our data (October 1st, 2023). Finally, we insert the rows from this new table into our fires table.
crunchData.ts
import { SimpleTable } from "@nshiab/simple-data-analysis";
 
export default async function crunchData(fires: SimpleTable) {
  await fires.cache(async () => {
    await fires.loadData(
      "https://raw.githubusercontent.com/nshiab/data-fetch-lesson/refs/heads/main/reported_fires_2023.csv",
    );
  });
 
  await fires.filter(`startdate >= '2023-04-15' && startdate <= '2023-10-01'`);
  await fires.summarize({
    values: "hectares",
    categories: ["province", "startdate"],
    summaries: "sum",
    decimals: 1,
  });
  await fires.accumulate("sum", "cumulativeHectares", {
    categories: "province",
  });
  await fires.round("cumulativeHectares", { decimals: 1 });
  const maxValuesPerProvince = await fires.summarize({
    values: "cumulativeHectares",
    categories: "province",
    summaries: "max",
    outputTable: "maxValuesPerProvince",
  });
  await maxValuesPerProvince.removeColumns("value");
  await maxValuesPerProvince.renameColumns({ max: "cumulativeHectares" });
  await maxValuesPerProvince.addColumn("startdate", "date", `'2023-10-01'`);
  await fires.insertTables(maxValuesPerProvince);
}

A screenshot showing a line chart with aligned labels.

And now, our labels are properly aligned!

Let’s go back to visualizeData.ts to fix the right margin. We can also filter out some labels to prevent overlapping text.

visualizeData.ts
import { SimpleTable } from "@nshiab/simple-data-analysis";
import { dot, line, plot, selectLast, text } from "@observablehq/plot";
 
export default async function visualizeData(fires: SimpleTable) {
  const drawChart = (data: unknown[]) =>
    plot({
      inset: 10,
      marginRight: 110,
      y: {
        grid: true,
        label: "Area burned (hectares)",
        ticks: 5,
        tickFormat: (d) => `${d / 1_000_000}M`,
      },
      x: {
        label: "Date",
      },
      color: {
        scheme: "tableau10",
      },
      marks: [
        line(data, {
          x: "startdate",
          y: "cumulativeHectares",
          stroke: "province",
        }),
        dot(
          data,
          selectLast({
            x: "startdate",
            y: "cumulativeHectares",
            fill: "province",
          }),
        ),
        text(
          data,
          selectLast({
            x: "startdate",
            y: "cumulativeHectares",
            z: "province",
            text: "province",
            fill: "black",
            stroke: "white",
            textAnchor: "start",
            dx: 5,
            filter: (d) => d.cumulativeHectares >= 400_000,
          }),
        ),
      ],
    });
  await fires.writeChart(drawChart, "./sda/output/chart.png");
}

And here’s our chart! Pretty nice, isn’t it? 😊

The final line chart.

Of course, there’s much more you can do with Plot. Be sure to check the documentation and the examples.

And if you want to create charts that can be modified in software like Illustrator, just save them as .svg instead of .png.

Wildfires beeswarm chart

Our previous chart was quite traditional, but you can also create more unconventional visualizations that push the boundaries of data storytelling.

For example, let’s create a beeswarm of our fires.

We can update crunchData.ts to return to our raw data. However, let’s keep only fires greater than 1 hectare. That leaves us with around 2,400 fires. This is quite a lot, considering we want to visualize each fire individually!

crunchData.ts
import { SimpleTable } from "@nshiab/simple-data-analysis";
 
export default async function crunchData(fires: SimpleTable) {
  await fires.cache(async () => {
    await fires.loadData(
      "https://raw.githubusercontent.com/nshiab/data-fetch-lesson/refs/heads/main/reported_fires_2023.csv",
    );
  });
  await fires.filter(`hectares > 1`);
}

And let’s start by drawing our fires as simple dots on a y-axis.

visualizeData.ts
import { SimpleTable } from "@nshiab/simple-data-analysis";
import { dot, plot } from "@observablehq/plot";
 
export default async function visualizeData(fires: SimpleTable) {
  await fires.logTable();
  const drawChart = (data: unknown[]) =>
    plot({
      marks: [
        dot(data, { y: "hectares" }),
      ],
    });
  await fires.writeChart(drawChart, "./sda/output/chart.png");
}

The fires as simple dots.

Right now, our fires are overlapping. The purpose of a beeswarm chart is to stack data points in a way that ensures each one remains visible.

To create a beeswarm, we can use the dodge transform with the middle option. Let’s update our code.

Rendering this chart may take a few seconds, depending on your machine’s processing power.

visualizeData.ts
import { SimpleTable } from "@nshiab/simple-data-analysis";
import { dodgeX, dot, plot } from "@observablehq/plot";
 
export default async function visualizeData(fires: SimpleTable) {
  await fires.logTable();
  const drawChart = (data: unknown[]) =>
    plot({
      marks: [
        dot(data, dodgeX("middle", { y: "hectares" })),
      ],
    });
  await fires.writeChart(drawChart, "./sda/output/chart.png");
}

Our first beeswarm.

It looks like we have a looooot of small wildfires and a few very big ones.

A logarithmic scale could help with that! Let’s change our y scale type to log.

visualizeData.ts
import { SimpleTable } from "@nshiab/simple-data-analysis";
import { dodgeX, dot, plot } from "@observablehq/plot";
 
export default async function visualizeData(fires: SimpleTable) {
  await fires.logTable();
  const drawChart = (data: unknown[]) =>
    plot({
      y: {
        type: "log",
      },
      marks: [
        dot(data, dodgeX("middle", { y: "hectares" })),
      ],
    });
  await fires.writeChart(drawChart, "./sda/output/chart.png");
}

The beeswarm chart with a logarithmic scale.

This is already much better. Another improvement would be to scale the dot radius based on fire size.

We can update the options for the dot function and adjust the r scale to ensure a minimum radius of 1 pixel and a maximum of 20 pixels.

visualizeData.ts
import { SimpleTable } from "@nshiab/simple-data-analysis";
import { dodgeX, dot, plot } from "@observablehq/plot";
 
export default async function visualizeData(fires: SimpleTable) {
  await fires.logTable();
  const drawChart = (data: unknown[]) =>
    plot({
      y: {
        type: "log",
      },
      r: {
        range: [1, 20],
      },
      marks: [
        dot(data, dodgeX("middle", { y: "hectares", r: "hectares" })),
      ],
    });
  await fires.writeChart(drawChart, "./sda/output/chart.png");
}

The beeswarm chart with the radius tied to the hectares burned.

We are getting there.

Let’s fill the circles based on the cause of the fire and add a legend.

visualizeData.ts
import { SimpleTable } from "@nshiab/simple-data-analysis";
import { dodgeX, dot, plot } from "@observablehq/plot";
 
export default async function visualizeData(fires: SimpleTable) {
  await fires.logTable();
  const drawChart = (data: unknown[]) =>
    plot({
      y: {
        type: "log",
      },
      r: {
        range: [1, 20],
      },
      color: {
        legend: true,
      },
      marks: [
        dot(
          data,
          dodgeX("middle", { y: "hectares", r: "hectares", fill: "cause" }),
        ),
      ],
    });
  await fires.writeChart(drawChart, "./sda/output/chart.png");
}

The beeswarm chart with the color depending on the cause of fire.

To help compare the causes, we could use facets. Let’s do that to create three small charts instead of one large one.

To achieve this, we use the fx option to create horizontal facets. Let’s also increase the chart width for better readability.

visualizeData.ts
import { SimpleTable } from "@nshiab/simple-data-analysis";
import { dodgeX, dot, plot } from "@observablehq/plot";
 
export default async function visualizeData(fires: SimpleTable) {
  await fires.logTable();
  const drawChart = (data: unknown[]) =>
    plot({
      width: 800,
      y: {
        type: "log",
      },
      r: {
        range: [1, 20],
      },
      color: {
        legend: true,
      },
      marks: [
        dot(
          data,
          dodgeX("middle", {
            y: "hectares",
            r: "hectares",
            fill: "cause",
            fx: "cause",
          }),
        ),
      ],
    });
  await fires.writeChart(drawChart, "./sda/output/chart.png");
}

The beeswarm chart with facets on the cause of fires.

This is starting to look very good. We probably don’t need the color legend anymore.

Let’s tweak the labels and axes, then call it a day!

visualizeData.ts
import { SimpleTable } from "@nshiab/simple-data-analysis";
import { dodgeX, dot, plot } from "@observablehq/plot";
 
export default async function visualizeData(fires: SimpleTable) {
  await fires.logTable();
  const drawChart = (data: unknown[]) =>
    plot({
      width: 800,
      marginTop: 40,
      y: {
        type: "log",
        label: "Hectares",
        ticks: 5,
        grid: true,
      },
      r: {
        range: [1, 20],
      },
      fx: {
        label: null,
      },
      marks: [
        dot(
          data,
          dodgeX("middle", {
            y: "hectares",
            r: "hectares",
            fill: "cause",
            fx: "cause",
          }),
        ),
      ],
    });
  await fires.writeChart(drawChart, "./sda/output/chart.png");
}

This is quite nice, isn’t it? By playing with marks and transforms, you can create stunning data visualizations with SDA and Plot.

Again, I encourage you to check the Plot documentation and examples. You’ll have a lot of fun with this library! 💃🕺

The beeswarm chart.

Fires map

With SDA, you can work with both tabular and geospatial data. When wrangling geospatial data, you often want to create maps!

Let’s map our fires along with the Canadian province boundaries.

First, we need to store the fire geometries in the same table as the province boundaries.

Let’s reuse what we did in the previous lesson and update crunchData.ts. We’ll modify it to take a second table, provinces, as a parameter. We fetch and cache the province boundaries, then insert them into the fires table. Before inserting, we add a column isFire to the fires table to easily differentiate them later.

crunchData.ts
import { SimpleTable } from "@nshiab/simple-data-analysis";
 
export default async function crunchData(
  fires: SimpleTable,
  provinces: SimpleTable,
) {
  await fires.cache(async () => {
    await fires.loadData(
      "https://raw.githubusercontent.com/nshiab/data-fetch-lesson/refs/heads/main/reported_fires_2023.csv",
    );
    await fires.points("lat", "lon", "geom");
    await fires.addColumn("isFire", "boolean", `TRUE`);
  });
 
  await provinces.cache(async () => {
    await provinces.loadGeoData(
      "https://raw.githubusercontent.com/nshiab/simple-data-analysis/main/test/geodata/files/CanadianProvincesAndTerritories.json",
    );
  });
 
  await fires.insertTables(provinces, { unifyColumns: true });
}

Let’s remove everything except the logTable in visualizeData.ts for now.

visualizeData.ts
import { SimpleTable } from "@nshiab/simple-data-analysis";
 
export default async function visualizeData(fires: SimpleTable) {
  await fires.logTable();
}

And let’s update main.ts to create a new table provinces and pass it to crunchData.

main.ts
import { SimpleDB } from "@nshiab/simple-data-analysis";
import crunchData from "./helpers/crunchData.ts";
import visualizeData from "./helpers/visualizeData.ts";
 
const sdb = new SimpleDB();
const fires = sdb.newTable("fires");
const provinces = sdb.newTable("provinces");
 
await crunchData(fires, provinces);
await visualizeData(fires);
 
await sdb.done();

Here’s what you should see to start on the right foot.

VS Code running and watching main.ts.

Now, let’s work on our map in visualizeData. Instead of using writeChart, we must use writeMap.

Since we are working with geospatial data, writeMap passes the data as GeoJSON to the drawing function. This is why drawMap expects data with the type { features: { properties: { [key: string]: unknown } }[] }. Essentially, this means that our table data is now stored in each feature’s properties.

To easily work with the fires and the provinces, we first isolate them on lines 9-14. Then, in the plot function, we use the geo mark, which handles the geospatial coordinates of the features.

visualizeData.ts
import { SimpleTable } from "@nshiab/simple-data-analysis";
import { geo, plot } from "@observablehq/plot";
 
export default async function visualizeData(fires: SimpleTable) {
  await fires.logTable();
  const drawMap = (
    data: { features: { properties: { [key: string]: unknown } }[] },
  ) => {
    const firesPoints = data.features.filter(
      (feature) => feature.properties.isFire,
    );
    const provincesPolygons = data.features.filter(
      (feature) => !feature.properties.isFire,
    );
 
    return plot({
      marks: [
        geo(provincesPolygons),
        geo(firesPoints),
      ],
    });
  };
  await fires.writeMap(drawMap, "./sda/output/map.png");
}

VS Code showing a basic map.

As you can see in the screenshot above, the geo mark automatically detected the point coordinates of our fires and the polygon coordinates of the Canadian province boundaries. Pretty clever! 🤓

Right now, the projection is Mercator. Fortunately, Plot supports better projections. Let’s update the projection option:

  • Set the type to conic-conformal.
  • Use the rotate option to ensure the map isn’t tilted with this projection.
  • Use the domain option to help Plot make our geospatial data take up as much space as possible on the map.
visualizeData.ts
import { SimpleTable } from "@nshiab/simple-data-analysis";
import { geo, plot } from "@observablehq/plot";
 
export default async function visualizeData(fires: SimpleTable) {
  await fires.logTable();
  const drawMap = (
    data: { features: { properties: { [key: string]: unknown } }[] },
  ) => {
    const firesPoints = data.features.filter(
      (feature) => feature.properties.isFire,
    );
    const provincesPolygons = data.features.filter(
      (feature) => !feature.properties.isFire,
    );
 
    return plot({
      projection: {
        type: "conic-conformal",
        rotate: [100, -60],
        domain: data,
      },
      marks: [
        geo(provincesPolygons),
        geo(firesPoints),
      ],
    });
  };
  await fires.writeMap(drawMap, "./sda/output/map.png");
}

VS Code showing a conic conformal projection.

This is looking better. Let’s style our province boundaries to create a nice light background.

visualizeData.ts
import { SimpleTable } from "@nshiab/simple-data-analysis";
import { geo, plot } from "@observablehq/plot";
 
export default async function visualizeData(fires: SimpleTable) {
  await fires.logTable();
  const drawMap = (
    data: { features: { properties: { [key: string]: unknown } }[] },
  ) => {
    const firesPoints = data.features.filter(
      (feature) => feature.properties.isFire,
    );
    const provincesPolygons = data.features.filter(
      (feature) => !feature.properties.isFire,
    );
 
    return plot({
      projection: {
        type: "conic-conformal",
        rotate: [100, -60],
        domain: data,
      },
      marks: [
        geo(provincesPolygons, {
          stroke: "lightgray",
          fill: "whitesmoke",
        }),
        geo(firesPoints),
      ],
    });
  };
  await fires.writeMap(drawMap, "./sda/output/map.png");
}

VS Code showing province boundaries as light grey.

Now, let’s work on the fires. Since they are point geometries, Plot automatically represents them as dots using the geo mark.

But nothing stops us from using another mark! Let’s create a spike map instead.

Because the fires are a collection of GeoJSON features, we need to use arrow functions to specify where Plot should find the right values for the spike options.

visualizeData.ts
import { SimpleTable } from "@nshiab/simple-data-analysis";
import { geo, plot, spike } from "@observablehq/plot";
 
export default async function visualizeData(fires: SimpleTable) {
  await fires.logTable();
  const drawMap = (
    data: { features: { properties: { [key: string]: unknown } }[] },
  ) => {
    const firesPoints = data.features.filter(
      (feature) => feature.properties.isFire,
    );
    const provincesPolygons = data.features.filter(
      (feature) => !feature.properties.isFire,
    );
 
    return plot({
      projection: {
        type: "conic-conformal",
        rotate: [100, -60],
        domain: data,
      },
      length: {
        range: [1, 100],
      },
      marks: [
        geo(provincesPolygons, {
          stroke: "lightgray",
          fill: "whitesmoke",
        }),
        spike(firesPoints, {
          x: (d) => d.properties.lon,
          y: (d) => d.properties.lat,
          length: (d) => d.properties.hectares,
        }),
      ],
    });
  };
  await fires.writeMap(drawMap, "./sda/output/map.png");
}

VS Code showing a basic spike map.

This is quite interesting! Now, let’s color-code the spikes based on the cause of the wildfires.

visualizeData.ts
import { SimpleTable } from "@nshiab/simple-data-analysis";
import { geo, plot, spike } from "@observablehq/plot";
 
export default async function visualizeData(fires: SimpleTable) {
  await fires.logTable();
  const drawMap = (
    data: { features: { properties: { [key: string]: unknown } }[] },
  ) => {
    const firesPoints = data.features.filter(
      (feature) => feature.properties.isFire,
    );
    const provincesPolygons = data.features.filter(
      (feature) => !feature.properties.isFire,
    );
 
    return plot({
      projection: {
        type: "conic-conformal",
        rotate: [100, -60],
        domain: data,
      },
      length: {
        range: [1, 100],
      },
      color: {
        legend: true,
      },
      marks: [
        geo(provincesPolygons, {
          stroke: "lightgray",
          fill: "whitesmoke",
        }),
        spike(firesPoints, {
          x: (d) => d.properties.lon,
          y: (d) => d.properties.lat,
          length: (d) => d.properties.hectares,
          stroke: (d) => d.properties.cause,
        }),
      ],
    });
  };
  await fires.writeMap(drawMap, "./sda/output/map.png");
}

VS Code showing a colored spike map.

We have a lot of overlapping spikes. By default, Plot draws the spikes in the order they appear in the data. Let’s change that to create a nice 3D effect.

Let’s update crunchData to sort the fires in descending order by their latitude (lat) values. This way, the fires with a greater latitude (further north) will be drawn first and will be overlapped by the ones with a smaller latitude (further south). This approach adds some perspective to the map.

Thank you to Philippe Rivière for the suggestion! 😊

crunchData.ts
import { SimpleTable } from "@nshiab/simple-data-analysis";
 
export default async function crunchData(
  fires: SimpleTable,
  provinces: SimpleTable,
) {
  await fires.cache(async () => {
    await fires.loadData(
      "https://raw.githubusercontent.com/nshiab/data-fetch-lesson/refs/heads/main/reported_fires_2023.csv",
    );
    await fires.points("lat", "lon", "geom");
    await fires.addColumn("isFire", "boolean", `TRUE`);
    await fires.sort({ lat: "desc" });
  });
 
  await provinces.cache(async () => {
    await provinces.loadGeoData(
      "https://raw.githubusercontent.com/nshiab/simple-data-analysis/main/test/geodata/files/CanadianProvincesAndTerritories.json",
    );
  });
 
  await fires.insertTables(provinces, { unifyColumns: true });
}

Also, by default, Plot assigns an opacity of 0.3 to the fill color of the spikes. Instead, we can use paler colors that match the color legend. Removing the transparency will help reduce visual confusion on the map.

Additionally, we can increase the maximum spike height for better visibility.

visualizeData.ts
import { SimpleTable } from "@nshiab/simple-data-analysis";
import { geo, plot, spike } from "@observablehq/plot";
 
export default async function visualizeData(fires: SimpleTable) {
  await fires.logTable();
  const drawMap = (
    data: { features: { properties: { [key: string]: unknown } }[] },
  ) => {
    const firesPoints = data.features.filter(
      (feature) => feature.properties.isFire,
    );
    const provincesPolygons = data.features.filter(
      (feature) => !feature.properties.isFire,
    );
 
    return plot({
      projection: {
        type: "conic-conformal",
        rotate: [100, -60],
        domain: data,
      },
      length: {
        range: [1, 200],
      },
      color: {
        legend: true,
      },
      marks: [
        geo(provincesPolygons, {
          stroke: "lightgray",
          fill: "whitesmoke",
        }),
        spike(firesPoints, {
          x: (d) => d.properties.lon,
          y: (d) => d.properties.lat,
          length: (d) => d.properties.hectares,
          stroke: (d) => d.properties.cause,
          fillOpacity: 1,
          fill: (d) => {
            if (d.properties.cause === "Human") {
              return "#b5caff";
            } else if (d.properties.cause === "Natural") {
              return "#ffe6a8";
            } else {
              return "#ffb9ad";
            }
          },
        }),
      ],
    });
  };
  await fires.writeMap(drawMap, "./sda/output/map.png");
}
💡

There are many ways to represent colors. Here, I am using HEX color values. Read this if you want to know more.

This is looking great! To learn more about maps, check out the geo mark documentation as well as the projection documentation.

Our final spike map.

Conclusion

Congrats! You now know how to create data visualizations with SDA and Plot!

I hope the step-by-step examples helped you understand how to build everything from simple charts to more innovative ones using both tabular and geospatial data.

Of course, these visualizations are static. There’s no user interaction and no animations since they are saved as images.

But don’t worry! We’ll reuse everything we’ve learned here to create interactive and animated data visualizations on the web in a future lesson! 😁

See you soon!

Enjoyed this? Want to know when new lessons are available? Subscribe to the newsletter ✉️ and give a ⭐ to the GitHub repository to keep me motivated! Get in touch if you have any questions.