JOUR 73361
Coding the News
Learn how America's top news organizations escape rigid publishing systems to design beautiful data-driven stories on deadline.
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.
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.
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.
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 installStart the development server:
npm run devAnd 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 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.
The key ones this week are:
| Component | Description |
|---|---|
Map | The container for your map, as well as its base configuration. |
MapLayer | A single data layer. Drop it inside <Map> to plot points, lines and polygons. |
Geocoder | A search box that turns an address into coordinates. |
Legend | A 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.
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."
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 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:
| Property | Description |
|---|---|
circle-color | The fill color of each point. |
circle-radius | The size of each point, in pixels. |
circle-stroke-color | The color of the outline around each point. |
circle-stroke-width | The width of the outline, in pixels. |
circle-opacity | The 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.
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.
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.
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.
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.
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.
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.
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.
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>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.
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
Mapcomponent with data plotted from your chosen dataset - A
Geocoderfor 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

















