JOUR 73361

Coding the News

Learn how America's top news organizations escape rigid publishing systems to design beautiful data-driven stories on deadline.

Ben Welsh, Adjunct Assistant Professor

Spring 2026

Mondays 6–9 p.m.

Lab 436

Week 11

Interactive Map

How to invite readers to explore geographic data

Apr. 20, 2026

In the summer of 2024, reporters at Gothamist published "NYPD data shows most shootings occur on the same blocks, year after year," a data-driven investigation into gun violence in New York City.

The analysis, by reporters Brittany Kriegstein and Jaclyn Jeffrey-Wilensky, showed that shooting incidents were not, as some might assume, a widespread problem that reached every corner of the city.

Hot spots: NYPD data shows most shootings occur on the same blocks, year after year
https://gothamist.com/news/hot-spots-nypd-data-shows-most-shootings-occur-on-the-same-blocks-year-after-year

Instead, by downloading and mapping the data published to the city's open data portal, they found that gunfire was concentrated in a small number of areas, with nine zones accounting for a massive share of shootings.

"Just 4% of New York City’s 120,000 blocks account for nearly all of the city's shootings," the story reported. "Life even two or three blocks away from these nine hot spots can be dramatically different, with less violence, fewer crimes and residents who say they feel relatively safe."

Alongside the story, Gothamist published an interactive map that allowed readers to see their findings and explore the data for themselves. It shaded every Census block in the five boroughs by how many shooting incidents the NYPD recorded there over the previous four years.

Map: Where New York City's shootings happen
https://gothamist.com/news/new-york-city-gun-violence-map

Their work follows a common model for data journalism: Mapping a dataset to reveal patterns and steer reporting, then publishing an interactive version to let readers place themselves in the story, regardless of where they live.

Today you are going to learn how to do just that. We'll cover how to plot points, shade areas and invite users to interact with the data.

Like Gothamist, our source will be the New York City data portal. But instead of crime, we'll be mapping the city's massive population of trees.

Part 1: Making your first map

The first step is getting a bare map on the page. Let's begin.

Open your browser and navigate to our template repository at github.com/palewire/cuny-jour-static-site-template.

cuny-jour-static-site-template
https://github.com/palewire/cuny-jour-static-site-template

Click "Use this template" and create a new repository called nyc-tree-map. Clone it, open it in Visual Studio Code, and install the dependencies.

npm install

Start the development server:

npm run dev

And open localhost:5173 in your browser.

Meet MapLibre

MapLibre GL JS is an open-source library for rendering vector maps in the browser. Give it some coordinates, a zoom level and a style for its base map, and it draws a fully interactive map. It's free to use, it integrates nicely with SvelteKit and, thus, it powers many of the maps you see on news sites.

MapLibre
https://maplibre.org/

MapLibre has an extensive JavaScript API governing every aspect of map rendering and interaction. Putting together a map like the one Gothamist publishes can require writing hundreds of lines of carefully constructed code. It can be a little daunting.

Rather than force everyone to climb the learning curve and rewrite similar code snippets for every project, well organized newsrooms build reusable components that encapsulate the most common patterns. That way, a newsroom developer can drop a map on the page with a few lines of code, and then customize it as needed.

As in past weeks, your class template already includes the components we'll use this week. Like the rest of the library, they're documented on the Storybook site.

Storybook
https://palewire.github.io/cuny-jour-static-site-template/storybook/?path=/docs/compositions-interactive-map--docs

The key ones this week are:

ComponentDescription
MapThe container for your map, as well as its base configuration.
MapLayerA single data layer. Drop it inside <Map> to plot points, lines and polygons.
GeocoderA search box that turns an address into coordinates.
LegendA styled box for explaining the map's symbology.

Open Storybook and poke at each one before moving on.

Creating a blank map

Open src/routes/+page.svelte and replace the boilerplate with this:

<script>
  import ArticleHeader from '$lib/components/Article/ArticleHeader.svelte';
  import Map from '$lib/components/Maps/Map.svelte';
</script>

<div class="container">
  <ArticleHeader
    headline="The Trees of New York"
    byline="NYCity News Service"
    pubDate="2026-04-20"
  />

  <Map
    longitude={-74.0}
    latitude={40.7}
    zoom={9.5}
    height={600}
    theme="positron"
    credit="OpenFreeMap / OpenStreetMap contributors"
  />
</div>

Save the file and check your browser. You should see a clean pale-gray map of New York City in our standard template.

The trees of New York
http://localhost:5173

Play with it a little. Drag the map around. Zoom in and out with the scroll wheel. Then try changing the theme prop to "liberty" or "bright" and save — the basemap should swap without you reloading the page.

Part 2: Putting points on the map

In 1985, the New York City Parks Department began an ambitious new venture. They called it the Great Tree Search, where the agency called on the public to nominate "trees of unusual size, species, form or historical association."

Great Trees of New York City
https://www.nycgovparks.org/facilities/great-trees

Forty years later, an updated roster lists 119 noteworthy trees, each a small piece of New York history.

I scraped those entries from the city website and converted them to the format preferred by MapLibre: GeoJSON.

GeoJSON is a standard format for encoding geographic data. It looks like regular JSON, but with a specific structure: a FeatureCollection containing Feature objects, each with properties and geometry that describe what the thing is and where it sits on the map.

Here's one example from the file:

{
  "type": "Feature",
  "geometry": {
    "type": "Point",
    "coordinates": [-73.9843, 40.7342]
  },
  "properties": {
    "id": 34,
    "species": "English elm",
    "description": "Western gate of Stuyvesant Square Park",
    "image": "https://www.nycgovparks.org/images/facilities/uploads/small/54dd2a39155b4.jpg"
  }
}

Notice the coordinates are [longitude, latitude] with the x-axis position first. This is the standard for GeoJSON, but it's the opposite of how some other mapping programs store geographic data. Be sure you get that order right when you build your own GeoJSON files.

We'll fetch the file using the same +page.js load function pattern you've used in previous weeks.

First download the file I've prepared from this link and place it in lib/data/great-trees.json in your project.

Create src/routes/+page.js:

import greatTrees from '$lib/data/great-trees.json';

export function load() {
  return {
    showHeader: true,
    showFooter: true,
    greatTrees,
  };
}

Now, as in past weeks, we can add a $props line to our script in +page.svelte to receive the data.

Update the <script> block in +page.svelte to receive it:

<script>
  import ArticleHeader from '$lib/components/Article/ArticleHeader.svelte';
  import Map from '$lib/components/Maps/Map.svelte';

  let { data } = $props();
  const greatTrees = data.greatTrees;
  console.log(greatTrees);
</script>

Refresh the page and open the browser's developer console. You should see the full GeoJSON object logged there, with a features array containing 119 entries.

Adding a layer

The template's MapLayer component takes a GeoJSON collection and a MapLibre paint object and renders the data on the parent map. You want to import it and drop it as a child of <Map>, which tells MapLibre to render it as a layer on top of the basemap.

Here's a fully functioning example with some simple styles and a new top to the page.

<script>
  import ArticleHeader from '$lib/components/Article/ArticleHeader.svelte';
  import Map from '$lib/components/Maps/Map.svelte';
  import MapLayer from '$lib/components/Maps/MapLayer.svelte';

  let { data } = $props();
  const greatTrees = data.greatTrees;
  console.log(greatTrees);
</script>

<div class="container">
  <ArticleHeader
    headline="The Great Trees of New York"
    byline="NYCity News Service"
    pubDate="2026-04-20"
  />

  <p>
    In 1985, the New York City Parks Department began an ambitious new venture.
    They called it the Great Tree Search.
  </p>

  <p>
    The agency called on the public to nominate "trees of unusual size, species,
    form or historical association." Explore them here and see if you can find any near you.
  </p>

  <Map
    longitude={-74.0}
    latitude={40.7}
    zoom={9.5}
    height={600}
    theme="positron"
    credit="OpenFreeMap / OpenStreetMap contributors"
  >
    <MapLayer
      id="great-trees"
      type="circle"
      data={greatTrees}
      paint={{
        'circle-color': '#0033a1',
        'circle-radius': 5,
        'circle-stroke-color': '#1a1a1a',
        'circle-stroke-width': 1,
        'circle-opacity': 0.9,
      }}
    />
  </Map>
</div>

Save. Your map should now be studded with blue dots.

The Great Trees of New York
http://localhost:5173

The type property in MapLayer tells MapLibre how to draw the data. Since our GeoJSON features are points, we use the circle type, which draws a simple circle at each point's coordinates. If our data had lines or polygons, we would use the line or fill types instead.

The data property is where you pass the GeoJSON collection. MapLibre reads the coordinates and properties of each feature and draws them on the map.

They're styled by the paint object you passed to MapLayer. MapLibre has a huge catalog of styling options for every type of layer. The ones we used here are:

PropertyDescription
circle-colorThe fill color of each point.
circle-radiusThe size of each point, in pixels.
circle-stroke-colorThe color of the outline around each point.
circle-stroke-widthThe width of the outline, in pixels.
circle-opacityThe opacity of the whole point, from 0 to 1.

You can experiment with other paint properties in the MapLibre Style Specification to customize the look of your points. Try tweaking the color, size and opacity to see how it changes the map.

Adding a popup

The dots are nice, but they don't tell you anything about the trees. In practice, most maps offer a way to click on a feature and get more information about it.

Our MapLayer component accepts a popup property that will handle all the code for you. All you need to do is provide a function that takes a feature and returns the HTML you want to show in the popup.

Add a simple popup to your MapLayer:

  <MapLayer
    id="great-trees"
    type="circle"
    data={greatTrees}
    paint={{
      'circle-color': '#0033a1',
      'circle-radius': 5,
      'circle-stroke-color': '#1a1a1a',
      'circle-stroke-width': 1,
      'circle-opacity': 0.9,
    }}
    popup={(feature) => {
      const p = feature.properties;
      return `<strong>${p.species}</strong><br>${p.description}`;
    }}
  />

MapLayer calls the function every time a dot is clicked and passes it the full feature, so you can build whatever HTML you'd like around the data. Keep it simple for now: the species on top, the location underneath.

Save. Click one of the blue dots. A popup should appear.

The Great Trees of New York
http://localhost:5173

Part 3: Searching by location

A zoomed map centered on the whole city is fine for seeing the overall distribution, but it doesn't make it easy for readers to find the trees near them. We can solve that problem by adding a search box that lets readers type in an address and fly the map to that location.

The process of turning a human-readable place, like Kips Bay or Flushing, into coordinates pinned to a map is a common problem in cartography. The technical term for it is geocoding. Every interactive map with a search box you have ever used is leaning on a geocoder under the hood to turn your query into a point on the map.

Most newsrooms pay a commercial geocoding provider like Google Maps or Mapbox for this service, but there are also free and open-source geocoders available that do the same thing without charging you for every search. They just aren't as good.

The class template uses Nominatim, the open-source geocoder run by the OpenStreetMap project. It's free to use at modest volumes with no API key required.

The template's Geocoder component wraps it in a styled search box.

Geocoder – Storybook
https://palewire.github.io/cuny-jour-static-site-template/storybook/?path=/docs/maps-geocoder--docs

When a user picks a result, Geocoder fires an onresult callback with its coordinates. All you have to do is hand those coordinates to our Map.

You can do that in +page.svelte. Import the component and add reactive state variables for the coordinates and zoom:

<script>
  import ArticleHeader from '$lib/components/Article/ArticleHeader.svelte';
  import Map from '$lib/components/Maps/Map.svelte';
  import MapLayer from '$lib/components/Maps/MapLayer.svelte';
  import Geocoder from '$lib/components/Maps/Geocoder.svelte';

  let { data } = $props();
  const greatTrees = data.greatTrees;
  let longitude = $state(-74.0);
  let latitude = $state(40.7);
  let zoom = $state(9.5);
</script>

Now replace the hard-coded numbers in the <Map> tag with those state variables, and add a <Geocoder> above the map with a callback that updates them.

  <p>
    The agency called on the public to nominate "trees of unusual size, species,
    form or historical association." Explore them here and see if you can find any near you.
  </p>

  <Geocoder
    label="Find your neighborhood"
    placeholder="Enter an address in New York…"
    onresult={(result) => {
      longitude = result.lng;
      latitude = result.lat;
      zoom = 15;
    }}
  />

  <Map
    {longitude}
    {latitude}
    {zoom}
    height={600}
    theme="positron"
    credit="OpenFreeMap / OpenStreetMap contributors"
  >
    <!-- the MapLayer you added earlier stays as-is -->
  </Map>

Save and try it. Type a New York City address into the search box, pick a result from the dropdown and watch the map glide over to your block.

The Great Trees of New York
http://localhost:5173

This is the reactive pattern you know from previous lessons, now connected to a map. The Geocoder updates three state variables. Those variables flow into the Map as properties. The Map reacts by animating to the new location.

Part 4: Using data to style polygons

Every ten years, the Parks Department leads a citizen science effort called TreesCount. Thousands of volunteers canvass the city's street trees, recording their species, trunk diameter, health, and exact location. In the 2015 survey, they found more than 600,000 trees and 133 different species.

The full data set is, like so much else, available for download on the city's open data portal.

2015 Street Tree Census - Tree Data
https://data.cityofnewyork.us/Environment/2015-Street-Tree-Census-Tree-Data/uvpi-gqnh/about_data

One tree dominates that census: the London plane. It was the tree favored by Robert Moses, the unelected "power broker" who controlled city parks for decades in the mid 20th century. He made sure the hearty hybrid was planted everywhere in New York, where it can be found in every borough and on nearly every block.

According to the 2015 census, there are more than 87,000 London plane trees on New York's streets, 12% of the city's population of street trees.

London plane tree | Central Park Conservancy
https://www.centralparknyc.org/plants/london-plane

That's far too many points to put on a single map, but we can still tell a story by aggregating the data to a higher level, just as the Gothamist reporters did with shootings and Census blocks.

In this case, we'll aggregate the trees to the level of Neighborhood Tabulation Areas devised by city planners to group together similar neighborhoods for analysis. There were 195 NTAs in 2015, covering every inch of the city. It's a lot of data, but not so much that it will overwhelm the average web browser.

Neighborhood Tabulation Areas
https://www.nyc.gov/content/planning/pages/resources/datasets/neighborhood-tabulation

Before class, I prepared a file that cross referenced the tree census with the NTA boundaries, so that each NTA polygon carries a pre-computed count of how many London plane trees live inside it.

Download the file I've prepared from this link and save it as lib/data/planetrees-by-nta.json in your project, alongside the Great Trees file.

Each feature in this file is a polygon representing an NTA, with properties that describe the tree population living inside it. Here's an example:

{
  "ntacode": "BK44",
  "ntaname": "Madison",
  "boroname": "Brooklyn",
  "total_all": 3437,
  "no_1_species": "London planetree",
  "top_species": [
    {
      "name": "London planetree",
      "count": 825
    },
    {
      "name": "Norway maple",
      "count": 293
    },
    {
      "name": "pin oak",
      "count": 264
    },
    {
      "name": "Japanese zelkova",
      "count": 171
    },
    {
      "name": "honeylocust",
      "count": 169
    }
  ],
  "total_planetree": 825,
  "rank_planetree": 1
}

Once you have the file saved, you should update +page.js to import it in +page.svelte, just like you did for the great trees.

import greatTrees from '$lib/data/great-trees.json';
import planetrees from '$lib/data/planetrees-by-nta.json';

export function load() {
  return {
    showHeader: true,
    showFooter: true,
    greatTrees,
    planetrees,
  };
}

And pull it out of the data prop in +page.svelte:

<script>
  import ArticleHeader from '$lib/components/Article/ArticleHeader.svelte';
  import Map from '$lib/components/Maps/Map.svelte';
  import MapLayer from '$lib/components/Maps/MapLayer.svelte';
  import Geocoder from '$lib/components/Maps/Geocoder.svelte';

  let { data } = $props();
  const greatTrees = data.greatTrees;
  const planetrees = data.planetrees;

  let longitude = $state(-74.0);
  let latitude = $state(40.7);
  let zoom = $state(9.5);
</script>

We're going to replace our points with a choropleth map, which is a technical term for a visualization that shades each region of a map by a numeric value. In this case, we're going to shade each NTA by its total_planetree, which contains the count of London plane trees living inside it. The darker the blue, the more plane trees.

Remove the Great Tree MapLayer from inside your <Map> block. In its place, add two new MapLayers: one that fills each NTA with a color based on its London plane tree count, and one that draws the outlines between them.

  <Map
    {longitude}
    {latitude}
    {zoom}
    height={600}
    theme="positron"
    credit="OpenFreeMap / OpenStreetMap contributors"
  >
    <MapLayer
      id="nta-fill"
      type="fill"
      data={planetrees}
      paint={{
        'fill-color': [
          'step',
          ['get', 'total_planetree'],
          '#eef2f9',
          147,
          '#c2cfe6',
          269,
          '#8098cc',
          434,
          '#3366b3',
          697,
          '#0033a1',
        ],
        'fill-opacity': 0.7,
      }}
    />
    <MapLayer
      id="nta-outline"
      type="line"
      data={planetrees}
      paint={{
        'line-color': '#0033a1',
        'line-width': 0.5,
      }}
    />
  </Map>

In this example, the paint input gets considerably more complex. The fill-color property is now an array that tells MapLibre to step through a series of color breaks based on the value of total_planetree for each NTA.

The first color, #eef2f9, is for NTAs with 0 to 146 plane trees. The second color, #c2cfe6, is for NTAs with 147 to 268 plane trees, and so on up to the darkest shade, #0033a1, which is for NTAs with 697 or more plane trees. This is meant to break them, roughly, into quintiles, with each color representing about 20% of the NTAs.

Save. The blue dots are gone, and the city now fills with varying shades of blue.

Where to find the London plane, New York's most common tree
http://localhost:5173

You're now looking at Moses's New York. The darkest polygons are the NTAs where his tree is thickest on the ground.

Adding a popup

Finally, let's make those polygons clickable. Add a popup to the fill layer that tells the reader what they're looking at.

<MapLayer
  id="nta-fill"
  type="fill"
  data={planetrees}
  paint={{
    'fill-color': [
      'step',
      ['get', 'total_planetree'],
      '#eef2f9',
      147,
      '#c2cfe6',
      269,
      '#8098cc',
      434,
      '#3366b3',
      697,
      '#0033a1',
    ],
    'fill-opacity': 0.7,
  }}
  popup={(feature) => {
    const p = feature.properties;
    return `<strong>${p.ntaname}</strong><br/>${p.total_planetree} plane trees`;
  }}
/>

Save and click around. Each NTA now tells you how many plane trees it has.

Where to find the London plane, New York's most common tree
http://localhost:5173

Update our headline and lead-in to reflect the shift in subject. Drop this in above the map, but inside of the container div.

  <ArticleHeader
    headline="Where to find the London plane, New York's most common tree"
    byline="NYCity News Service"
    pubDate="2026-04-20"
  />

  <p>
    Every ten years, thousands of volunteers canvass the city's street trees,
    recording their species, trunk diameter, health, and exact location. In the
    2015 survey, they found more than 600,000 trees and 133 different species.
  </p>

  <p>
    One tree dominates the count: the London plane. It was the tree favored by
    Robert Moses, the unelected "power broker" who controlled city parks for
    decades in the mid 20th century. He made sure the hearty hybrid was planted
    everywhere in New York, where it can be found in every borough and on nearly
    every block.
  </p>

  <p>
    According to the 2015 census, there are more than 87,000 London plane
    trees on New York's streets, 12% of the city's population of street trees.
    Here's where the most London planes are found:
  </p>
Where to find the London plane, New York's most common tree
http://localhost:5173

The only thing missing is a legend to explain the colors. You can add one with the Legend component, which takes an array of labels and colors and renders a styled box.

First import the component at the top of your script tag.

<script>
  import ArticleHeader from '$lib/components/Article/ArticleHeader.svelte';
  import Geocoder from '$lib/components/Maps/Geocoder.svelte';
  import Map from '$lib/components/Maps/Map.svelte';
  import MapLayer from '$lib/components/Maps/MapLayer.svelte';
  import Legend from '$lib/components/Maps/Legend.svelte';
  // ...
</script>

Then add it at the bottom of the page, but just before the closing </div> tag. Pass it an array of objects with label and color properties that match the breaks you used in your fill layer.

  <Legend
    title="London plane trees"
    mode="threshold"
    items={[
      {
        color: '#eef2f9',
        to: 146,
      },
      {
        color: '#c2cfe6',
        from: 147,
        to: 268,
      },
      {
        color: '#8098cc',
        from: 269,
        to: 433,
      },
      {
        color: '#3366b3',
        from: 434,
        to: 696,
      },
      {
        color: '#0033a1',
        from: 697,
      },
    ]}
  />

Voila. You've done it, you've made a data-driven map.

Where to find the London plane, New York's most common tree
http://localhost:5173

Homework

Task 1: Publish an interactive map

Find a point or polygon dataset on the NYC Open Data portal, or choose one from your own work. Build a page in your project that maps it with MapLibre, using the components in the template.

Your project must have:

  • A Map component with data plotted from your chosen dataset
  • A Geocoder for navigation
  • A headline, an introductory paragraph, a legend and a methodology box that presents the map as a journalistic product
  • At least one feature we didn't cover in class, like a custom popup, a data filter or a more complex paint style

Deploy to GitHub Pages and send me the link.

Task 2: Prepare to present

Be ready to walk the class through your map and explain:

  • The dataset you chose and why
  • At least one editorial decision you made about what to show and what to leave out
  • Something you added to the map that we didn't cover in class, how you built it and why it's a good fit for your page