Portland urban growth boundary map with MapLibre GL JS
Oregon was the first state to require every city to draw an urban growth boundary, which separates the land a city can develop from the farms and forests around it, and it's a big part of what makes Portland such a great place to live.
Metro, Portland's regional government, draws one UGB for two dozen cities. Every six years it checks whether the land inside can hold the next 20 years of growth, and if it can't, Metro makes room inside the boundary, expands it, or both.
Play the years to watch the boundary, in pale orange, move out from 1979 and the cities, in blue, fill in toward it, or click a city's land to see when it joined.
Build it yourself
Metro serves both layers from ArcGIS Online, which answers requests from any website, so this map needs no script to collect them: one page asks Metro for the boundary changes and city annexations as GeoJSON, then plays through the years from 1979, with a slider to pick one. It's the map above without the legend, the summary, or Damascus. It was tested with MapLibre GL JS 6.15 in Chromium.
What you need
A web server to serve the page, and a browser key with the Map tiles product, made as API keys shows. Add http://localhost:* to its allowed origins, so it works from your computer while you build. Metro's data needs no key.
Make the page
Make a folder for the map, and in it index.html, with your browser key in place of mapricot_pk_…:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Portland's growth boundary</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@6.15.0/dist/maplibre-gl.css">
<script type="importmap">
{
"imports": {
"maplibre-gl": "https://cdn.jsdelivr.net/npm/maplibre-gl@6.15.0/+esm"
}
}
</script>
</head>
<body style="margin: 0">
<div style="position: absolute; z-index: 1; top: 10px; left: 10px; padding: 4px 8px; background: white; font: 16px sans-serif">
<button id="play" type="button">Pause</button>
<label><span id="year"></span> <input id="slider" type="range" min="1979"></label>
</div>
<div id="map" style="height: 100vh"></div>
<script type="module">
import * as maplibregl from "maplibre-gl";
const first = 1979;
const last = new Date().getFullYear();
const map = new maplibregl.Map({
container: "map",
style: "https://api.mapricot.com/tiles/v1/styles/light.json?key=mapricot_pk_…",
bounds: [
[-123.15, 45.25],
[-122.3, 45.68],
],
});
const loaded = map.once("load");
// Every feature of one of Metro's layers, 2,000 at a time, as GeoJSON in longitude and latitude.
async function layer(name, fields) {
const features = [];
for (let offset = 0; ; offset += 2000) {
const url = `https://services2.arcgis.com/McQ0OlIABe29rJJy/ArcGIS/rest/services/${name}/FeatureServer/0/query?where=1%3D1&outFields=${fields}&outSR=4326&geometryPrecision=4&maxAllowableOffset=0.0001&resultOffset=${offset}&f=geojson`;
const page = (await (await fetch(url)).json()).features;
features.push(...page);
if (page.length < 2000) return { type: "FeatureCollection", features };
}
}
const [boundary, cities] = await Promise.all([layer("UGB_Annexations", "UGB,YEAR"), layer("City_Annexations", "EFF_DATE,ACT_TYPE,COUNTY")]);
boundary.features = boundary.features.filter(({ properties }) => properties.YEAR);
const kept = ["Completed annexation to a district", "Formation of a new district"];
cities.features = cities.features.filter(({ properties }) => kept.includes(properties.ACT_TYPE) && properties.COUNTY !== "Clark");
for (const { properties } of cities.features) properties.YEAR = properties.EFF_DATE ? new Date(properties.EFF_DATE).getUTCFullYear() : 0;
await loaded;
const attribution =
'Regional Land Information System data are a product of <a href="https://rlisdiscovery.oregonmetro.gov/pages/open-database-license">Metro</a>';
map.addSource("boundary", { type: "geojson", data: boundary, attribution });
map.addSource("cities", { type: "geojson", data: cities });
map.addLayer({ id: "inside", type: "fill", source: "boundary", paint: { "fill-color": "#ffe8cc" } }, "water");
map.addLayer({ id: "removed", type: "fill", source: "boundary", paint: { "fill-color": "#ffffff" } }, "water");
map.addLayer(
{
id: "cities",
type: "fill",
source: "cities",
paint: { "fill-color": ["interpolate", ["linear"], ["get", "YEAR"], first - 1, "#c5d7ec", first, "#8fb4e3", last, "#16407f"] },
},
"water",
);
map.addLayer({ id: "added", type: "line", source: "boundary", paint: { "line-color": "#fd7e14", "line-width": 2 } });
const slider = document.querySelector("#slider");
const button = document.querySelector("#play");
slider.max = last;
function show(year) {
slider.value = year;
document.querySelector("#year").textContent = year;
map.setFilter("inside", ["all", ["==", ["get", "UGB"], 1], ["<=", ["get", "YEAR"], year]]);
map.setFilter("removed", ["all", ["==", ["get", "UGB"], 0], ["<=", ["get", "YEAR"], year]]);
map.setFilter("cities", ["<=", ["get", "YEAR"], year]);
map.setFilter("added", ["all", ["==", ["get", "UGB"], 1], ["==", ["get", "YEAR"], year], [">", ["get", "YEAR"], first]]);
}
let timer;
function play(on) {
clearInterval(timer);
timer = undefined;
button.textContent = on ? "Pause" : "Play";
if (!on) return;
if (Number(slider.value) === last) show(first);
timer = setInterval(() => {
const year = Number(slider.value) + 1;
show(year);
if (year === last) play(false);
}, 1000 / 3);
}
button.addEventListener("click", () => play(!timer));
slider.addEventListener("input", () => {
play(false);
show(Number(slider.value));
});
const still = matchMedia("(prefers-reduced-motion: reduce)").matches;
show(still ? last : first);
play(!still);
</script>
</body>
</html>
- Metro's layers are ArcGIS feature services.
queryreturns their features as GeoJSON in longitude and latitude withoutSR=4326andf=geojson, rounds coordinates to 4 decimal places (about 10 m) withgeometryPrecision, and simplifies shapes to about the same withmaxAllowableOffset, which takes the city annexations from about 12 MB to 2 MB. - A query returns at most 2,000 features, so
layerasks for the next 2,000 until a page comes back short. The boundary layer has about 240 features and the city layer about 3,900. - In the boundary layer,
UGBis1for land added to the boundary and0for land taken out. Land taken out is drawn on top in the style's white, so it disappears from the boundary in the year it left. One record has no year, so it's left out. - City annexations have
EFF_DATE, the date each took effect, in milliseconds. Each city's first record is a formation, and many have no date: that's the city as it was before the records, which start in the 1970s. They get year 0, the palest blue. Clark County's few records are cities in Washington, which Metro doesn't track. - The layers go in under the style's
water, so rivers, roads, and names stay on top. addedoutlines the land added to the boundary in the year shown, after 1979, so each move stands out as it happens. It goes on top of everything.showsets each layer's filter to a year, so the map redraws without asking Metro again, and moves the slider to match.playmoves on a year three times a second and stops at this year. Pause or a drag of the slider stops it, and Play starts again from the year shown, or from 1979 at the end. Anyone who prefers reduced motion sees this year's map instead, with the slider to go back.
Open it
Serve the folder with npx serve or python3 -m http.server, and open the address it prints. Opened straight from the file, the map stays blank, as Open the page explains. After a few seconds you should see the boundary as it was first drawn in 1979, in pale orange with the cities in blue over it. Then the years play: each year's additions are outlined in orange, and the cities fill in toward the boundary. It stops at this year. Drag the slider to pick a year, or press Play to watch again.
Data
- Growth boundary: UGB annexations, Metro's Regional Land Information System
- City limits: City annexations, Metro's Regional Land Information System
- Regional Land Information System data are a product of Metro and made publicly available according to the license agreement found in https://rlisdiscovery.oregonmetro.gov/pages/open-database-license.
- Map: OpenStreetMap contributors (ODbL), in Mapricot's map tiles