A single image shows us the landscape at one frozen moment. But what if we could watch that same spot change over months or years? That's what time series charts give us: the story of a place, unfolding pixel by pixel.
In this module, we'll learn to create interactive charts, compare regions side by side, and detect long-term trends.
Learning objectives
- Use
ui.Chart.image.series()to plot spectral values over time for a single location. - Compare multiple regions using
ui.Chart.image.seriesByRegion(). - Detect greening and browning trends with
ee.Reducer.linearFit(). - Interpret slope and offset values from linear regression.
- Map trends spatially across a landscape.
Why it matters
Time series analysis answers questions that single images simply cannot. Is this forest getting healthier or declining? When does the growing season start? Is a drought getting worse over time?
Charts transform pixel values into understandable narratives. And trend detection? It turns decades of data into a single map of change. That's a powerful insight.
Key vocabulary
- Time series
- A sequence of data points ordered in time, such as monthly NDVI values over several years.
- Linear fit
- A statistical method that fits a straight line through data points to reveal the overall trend (increasing, decreasing, or stable).
- Slope
- The steepness of the trend line. Positive slope means values are increasing over time (greening); negative slope means decreasing (browning).
- Offset (intercept)
- The starting value of the trend line where time equals zero.
- Phenology
- The study of seasonal biological cycles, such as leaf-out in spring and leaf-drop in autumn.
Your first chart: NDVI across a year
Let's chart the seasonal rise and fall of vegetation greenness for a forest point in north-central Florida over the year 2023. We'll see the whole seasonal rhythm in one picture.
// Cloud masking function for Landsat 8 SR
function maskL8sr(image) {
var qaMask = image.select('QA_PIXEL').bitwiseAnd(1 << 3).eq(0)
.and(image.select('QA_PIXEL').bitwiseAnd(1 << 4).eq(0));
return image.updateMask(qaMask)
.multiply(0.0000275).add(-0.2);
}
// Define a forest point
var forest = ee.Geometry.Point([-82.4, 29.7]);
// Load Landsat 8 for 2023, add NDVI band
var collection = ee.ImageCollection('LANDSAT/LC08/C02/T1_L2')
.filterDate('2023-01-01', '2023-12-31')
.filterBounds(forest)
.filter(ee.Filter.lt('CLOUD_COVER', 30))
.map(maskL8sr)
.map(function(img) {
var ndvi = img.normalizedDifference(['SR_B5', 'SR_B4']).rename('NDVI');
return img.addBands(ndvi);
});
// Create chart
var chart = ui.Chart.image.series({
imageCollection: collection.select('NDVI'),
region: forest,
reducer: ee.Reducer.mean(),
scale: 30
}).setOptions({
title: 'NDVI Over 2023 - Florida Forest',
vAxis: {title: 'NDVI'},
hAxis: {title: 'Date'},
lineWidth: 2,
pointSize: 4
});
print(chart);
What you should see
A line chart in the Console panel showing NDVI values from January to December. Look for lower values in winter (dormant season), a rise in spring, peak greenness in summer, and a decline in autumn. That seasonal heartbeat? That's phenology in action.
Why bother charting over time?
Maps show us spatial patterns. Charts show us temporal patterns. Together, they give us the complete picture. Here are real questions that time series charts can answer:
- Is this forest getting greener or browning? A multi-year NDVI chart reveals the long-term trajectory.
- When does the growing season start? The date when NDVI crosses a threshold tells us the onset of spring.
- How fast did vegetation recover after a fire? Charting NDVI from the fire year forward shows the recovery curve.
- How does cropland differ from forest? Side-by-side charts reveal fundamentally different seasonal signatures.
Here's the cool part: GEE provides built-in charting functions that plot values directly from image collections. No exporting required.
How the charting function works
The ui.Chart.image.series() function extracts values from every image
in a collection at a specified location and plots them over time. Let's break down
the key parameters:
imageCollection: The collection of images (select only the band you want to plot).region: The point or polygon where values are extracted.reducer: How to summarize pixel values within the region (useee.Reducer.mean()for a point).scale: The resolution for the extraction (30 for Landsat).
// Basic chart structure
var chart = ui.Chart.image.series({
imageCollection: collection.select('NDVI'),
region: forest,
reducer: ee.Reducer.mean(),
scale: 30
});
// Customize appearance
chart = chart.setOptions({
title: 'NDVI Time Series',
vAxis: {title: 'NDVI', minValue: 0, maxValue: 1},
hAxis: {title: 'Date'},
lineWidth: 2,
pointSize: 4,
series: {0: {color: 'green'}}
});
print(chart);
Reading the chart: Each point represents one cloud-free Landsat image. Gaps in the chart mean no clear image was available on that date. Spikes downward? Those often indicate residual cloud contamination that the mask didn't catch.
Comparing places side by side
Here's where it gets interesting. The ui.Chart.image.seriesByRegion()
function plots multiple locations on the same chart. This is incredibly powerful for
comparing how different land cover types respond to seasons and disturbances.
// Define three land cover points
var forest = ee.Geometry.Point([-82.4, 29.7]);
var urban = ee.Geometry.Point([-82.32, 29.65]);
var cropland = ee.Geometry.Point([-82.5, 29.5]);
// Create a FeatureCollection with labels
var regions = ee.FeatureCollection([
ee.Feature(forest, {'label': 'Forest'}),
ee.Feature(urban, {'label': 'Urban'}),
ee.Feature(cropland, {'label': 'Cropland'})
]);
// Build the multi-region chart
var regionChart = ui.Chart.image.seriesByRegion({
imageCollection: collection.select('NDVI'),
regions: regions,
reducer: ee.Reducer.mean(),
band: 'NDVI',
scale: 30,
seriesProperty: 'label'
}).setOptions({
title: 'NDVI by Land Cover Type (2023)',
vAxis: {title: 'NDVI'},
hAxis: {title: 'Date'},
lineWidth: 2,
pointSize: 4
});
print(regionChart);
What you should see
Three colored lines on one chart. Forest should show consistently high NDVI with gentle seasonal variation. Cropland should show a dramatic rise and fall matching the growing season. Urban should remain low and relatively flat year-round. It's like a fingerprint, unique to each land cover type.
Is this place greening or browning?
A chart shows us the pattern visually, but how do we quantify whether a location is
actually greening or browning? The ee.Reducer.linearFit() reducer fits
a straight line through the data and returns two values:
- Slope: The rate of change per unit of time. Positive slope means NDVI is increasing (greening). Negative slope means NDVI is decreasing (browning).
- Offset: The y-intercept, representing the baseline value.
To use linearFit(), we need to give the collection a time band. GEE uses
the system:time_start property as the x-axis automatically when we add
a time band to each image. Don't worry, we'll walk through it step by step.
// Build multi-year collection (2020-2023)
var longCollection = ee.ImageCollection('LANDSAT/LC08/C02/T1_L2')
.filterDate('2020-01-01', '2023-12-31')
.filterBounds(forest)
.filter(ee.Filter.lt('CLOUD_COVER', 30))
.map(maskL8sr)
.map(function(img) {
var ndvi = img.normalizedDifference(['SR_B5', 'SR_B4']).rename('NDVI');
// Add time band (fractional years)
var timeImage = ee.Image.constant(
img.date().difference('2020-01-01', 'year')
).float().rename('time');
return ndvi.addBands(timeImage);
});
// Apply linear regression
// linearFit expects bands in order: [independent (time), dependent (NDVI)]
var trend = longCollection.select(['time', 'NDVI'])
.reduce(ee.Reducer.linearFit());
// Print the trend at the forest point
print('Trend at forest:', trend.reduceRegion({
reducer: ee.Reducer.mean(),
geometry: forest,
scale: 30,
maxPixels: 1e9
}));
Interpreting the result: A slope of 0.01 means NDVI increases by 0.01 per year. Over four years, that's a total increase of 0.04 in NDVI. A slope near zero means no significant change. Simple as that.
Let's see the whole landscape at once
Here's where remote sensing gets powerful. We can apply linearFit()
to every pixel at once, not just a single point. The result is a trend map where
each pixel's color tells us whether that location is greening, stable, or browning.
// The 'trend' image from the previous step has two bands:
// 'scale' = slope, 'offset' = intercept
// Visualize the slope band with a diverging palette
Map.centerObject(forest, 10);
Map.addLayer(trend.select('scale'), {
min: -0.05, max: 0.05,
palette: ['red', 'orange', 'white', 'lightgreen', 'darkgreen']
}, 'NDVI Trend (Slope)');
// Add a reference point
Map.addLayer(forest, {color: 'blue'}, 'Forest Point');
What you should see
A map where green pixels indicate increasing NDVI (greening) over the 2020-2023 period. Red pixels show decreasing NDVI (browning or land cover change). White pixels have no significant trend. Congratulations, you just turned four years of satellite data into a single, readable map of change!
Pro tips
- Sort your collection: Charts display images in order. Always ensure your collection is sorted by date using
.sort('system:time_start')if needed. - Add chart labels: Always include
title,vAxis, andhAxislabels. Unlabeled charts are hard to interpret. - Use fractional years for time: Converting dates to fractional years makes slope values interpretable (change per year).
- Filter growing season only: For vegetation trends, consider filtering to summer months only. This removes seasonal noise from the trend calculation.
- Download chart data: Click the small arrow icon in the top-right corner of any GEE chart to download it as a CSV or SVG.
Try it: chart EVI for a different region
EVI (Enhanced Vegetation Index) is less sensitive to atmospheric effects than NDVI. Let's adapt the charting code to plot EVI instead. The formula for Landsat 8 is:
// EVI formula for Landsat 8
var evi = img.expression(
'2.5 * ((NIR - RED) / (NIR + 6 * RED - 7.5 * BLUE + 1))', {
'NIR': img.select('SR_B5'),
'RED': img.select('SR_B4'),
'BLUE': img.select('SR_B2')
}).rename('EVI');
// Try charting EVI for a point near your hometown
// Customize the chart title and axis labels
Common mistakes
- Not sorting by date: If images are unordered, the chart lines will zigzag randomly instead of showing a smooth temporal pattern.
- Too few images: A trend line fit through 5 points is unreliable. Aim for at least 30 images over 3+ years for meaningful trends.
- Ignoring cloud spikes: Sudden downward spikes in NDVI usually mean a cloudy pixel slipped past the mask. Apply strict cloud filtering before charting.
- Confusing slope with NDVI: The slope band from
linearFit()shows the rate of change, not the NDVI value itself. A pixel can have high NDVI but zero slope if it's stable. - Wrong band order for linearFit: The first band must be the independent variable (time). The second band must be the dependent variable (NDVI). Swapping them gives meaningless results.
Quick self-check
- What is the difference between
ui.Chart.image.series()andui.Chart.image.seriesByRegion()? - A pixel has a slope of -0.03 from
linearFit(). What does this mean in ecological terms? - Why should you use fractional years (instead of milliseconds) as the time band for linear regression?
- How would a cropland NDVI time series look different from a forest NDVI time series?
Going deeper
This module covers basic time series charting and linear trend detection. For more sophisticated time series methods, see:
What's next?
- Change Detection - Compare images from two dates to detect environmental change.
- Lab 22: Time Series Charting - Build multi-region charts and trend maps independently.