EGE 2531 · Jomo Kenyatta University of Agriculture and Technology

Web Mapping

A course reader for undergraduate Geomatic Engineering and Geospatial Information Systems.

11 teaching weeks 2 h lecture + 2 h laboratory Leaflet · PostGIS · GeoServer · Flask Assessment 30 / 70 Edition 1.0 · Sept 2026

How to use this reader

This is a build unit. By week 11 you will have deployed a working application, at a public URL, backed by a spatial database, that someone other than you can use. Everything in the reader is arranged toward that.

Each chapter covers one week and has the same parts: learning outcomes, the reading, a two-tier practical, self-test questions, and further reading. The Core practical is what everyone completes; the Extension is for those who finish early.

This unit is in two languages you have not used

Your programming unit was Python. This unit is JavaScript and SQL. Nothing about that is beyond you — but do not assume the transfer is automatic, because the two most common ways to struggle here are treating JavaScript as though it were Python, and treating SQL as though it were a programming language rather than a declarative one.

Work through Appendix A before week 2. It is a self-check, not a test, and it will tell you privately which of the two you need to spend a weekend on.

One habit to form now: keep the browser developer tools open at all times. Not when something breaks — always. The Network tab shows you every request your map makes, its size and its timing; the Console shows you the errors that would otherwise present as a silent blank grey box. Students who work with devtools open debug in minutes what students who do not debug in hours. This is the single highest-value habit in the unit.

Software setup

Five things, installed in this order. Weeks 1–4 need only the first two.

WhatWhyWhen
A code editor — VS CodeWith the Live Server extension, which serves your folder over HTTPweek 1
A modern browser — Firefox or ChromeFor the developer tools, which are half of this unitweek 1
PostgreSQL + PostGISThe spatial databaseweek 5
GDAL — for ogr2ogrLoading data into the database and converting between formatsweek 5
Python + FlaskThe API layer. This part is in Python — your existing skills applyweek 6
GeoServer (needs Java)Publishing OGC servicesweek 7
Do not open your HTML file by double-clicking it

Opening a file directly gives it a file:// address. Under that scheme the browser's security model blocks fetch(), so the moment you try to load GeoJSON from a separate file — week 3 — it fails with a CORS error that has nothing to do with CORS and everything to do with how you opened the file.

Always serve over HTTP. Use the Live Server extension, or run python -m http.server 8000 in your project folder and visit http://localhost:8000. Every hour of confusion this prevents is an hour you get to spend on maps.

Leaflet itself needs no installation — it is a CSS file and a JavaScript file loaded from a CDN, which is how week 3 starts.

01

Week one

Anatomy of a web map

By the end of this week you should be able to
  • Trace what happens between a user's click and the pixels that appear
  • Explain the tile pyramid and compute the tile index for a given point and zoom
  • Say what Web Mercator distorts, by how much, and where it does not matter
  • Distinguish raster tiles, vector tiles and GeoJSON, and say when each is appropriate
  • Read a map's network traffic in developer tools

What actually happens

You open a web map centred on Nairobi. In the second before it appears, this happens:

  1. The browser requests an HTML page. That page loads the Leaflet CSS and JavaScript.
  2. Leaflet creates a <div> and works out which geographic area it must cover at the requested zoom.
  3. It calculates which tiles — small fixed square images, conventionally 256 × 256 pixels — cover that area, typically nine to twenty of them.
  4. It issues one HTTP request per tile, in parallel, to a tile server.
  5. Each tile arrives as a PNG and is positioned absolutely inside the div by CSS.
  6. Your own data — points, boundaries — arrives separately as GeoJSON and is drawn as SVG or canvas on top.

The whole design rests on one decision: pre-render the base map once, into fixed tiles, and serve them as static images. Nothing is computed per user. A tile is just a file, so it caches in the browser, in the CDN, and at every proxy in between. This is why a web map can serve millions of users on modest infrastructure, and it is why the "slippy map" replaced the older approach of asking a server to render a fresh image for every pan.

The tile pyramid

z = 0z = 1z = 2 1 tile 4 tiles 16 tiles 4ᶻ tiles at zoom z · z = 19 → 275 billion whole world
Each zoom level quarters every tile of the one above. Zoom 0 is the entire world in one 256-pixel image. Every step doubles the linear resolution and quadruples the tile count. Nobody stores all of level 19 — tiles are generated on demand and cached, which is what GeoWebCache does in week 7.

A tile is addressed by three numbers: z (zoom), x (column, west to east) and y (row, north to south — the origin is the top-left corner, which surprises people used to y increasing northward). A tile URL is a template with those three slots:

https://tile.openstreetmap.org/{z}/{x}/{y}.png

The conversion from longitude and latitude to tile indices is:

x = ⌊ λ + 180360 · 2z ⌋ y = ⌊ 1 − asinh(tan φ) / π2 · 2z ⌋

λ is longitude and φ latitude in radians. The asinh term is the Mercator projection of latitude; everything peculiar about web maps traces back to it.

Worked example — which tile is Nairobi in?

Nairobi city centre: λ = 36.8219°E, φ = −1.2921°. Take zoom 12, so 212 = 4096.

Column. (36.8219 + 180) / 360 = 216.8219 / 360 = 0.602283. Multiply by 4096 → 2466.95, floor → x = 2466.

Row. φ = −1.2921° = −0.022551 rad. tan φ = −0.0225548. asinh(tan φ) = ln(−0.0225548 + 1.0002544) = ln(0.9776996) = −0.0225528.
(1 − (−0.0225528)/π) / 2 = (1 + 0.0071785) / 2 = 0.5035893. Multiply by 4096 → 2062.70, floor → y = 2062.

So Nairobi at zoom 12 is tile 12/2466/2062. Paste https://tile.openstreetmap.org/12/2466/2062.png into your browser and you will get a 256-pixel image of central Nairobi. Do this before the lecture. A web map is a great deal less mysterious once you have fetched a tile by hand.

Web Mercator, and where it lies

Almost every web map uses EPSG:3857, "Web Mercator". It was chosen for one practical reason: Mercator is conformal, so shapes and angles are locally correct and north is always up, which makes tiles that stitch seamlessly and pan intuitively. The price is area. Mercator inflates area by a factor of sec²φ — that is, 1/cos²(latitude).

LatitudeArea inflationWhere
0°×1.00Nairobi, Kampala, Kisumu
30°×1.33Cairo
45°×2.00Milan
60°×4.00Oslo, Anchorage
75°×14.9Northern Greenland

This is the source of the familiar Greenland problem. Greenland's true area is about 2.17 million km²; the Democratic Republic of the Congo's is about 2.34 million km². The DRC is genuinely the larger country. On a Web Mercator map Greenland appears roughly fourteen times bigger.

There is a local point worth noticing here. Kenya sits on the equator, so Web Mercator distortion across Kenya is negligible — at 1.3° the inflation factor is 1.0005, five parts in ten thousand. For a map of Nairobi County you may reason about Web Mercator areas without much guilt. For a map of Africa, or of the world, you may not. Knowing which situation you are in is the professional skill; assuming one or the other is how errors happen.

Never compute area or distance in Web Mercator

Even where the distortion is small it is not zero, and the habit is what matters. A Web Mercator area is in metres that are not real metres, and the scale factor varies across your map. Project to an equal-area or a local projection first — for Kenya, UTM zone 36S (EPSG:32736) or 37S (EPSG:32737). Week 5 shows the SQL version of this mistake, which is even easier to make and even harder to see.

Three ways to get data onto the map

ApproachHow it worksUse when
Raster tilesPre-rendered PNG/JPEG images. Styling is baked in at render time.Base maps; imagery; anything that does not change and need not be interactive.
Vector tilesCompressed geometry in tiles, styled and drawn by the browser.Large datasets needing interactivity, restyling or smooth zoom. Week 9.
GeoJSONThe whole dataset as one text file, drawn client-side.Small to moderate datasets — up to a few thousand features. Weeks 3–4.

The failure mode you will meet is loading a national dataset as a single GeoJSON, discovering it is 40 MB, and watching the browser freeze. The fix is not to optimise the GeoJSON; it is to recognise that you have chosen the wrong delivery mechanism. Week 9 covers the right one.

Standards, and why they exist

The Open Geospatial Consortium publishes the specifications that let software from different vendors interoperate. WMS returns a rendered image; WFS returns actual features; WMTS returns tiles. All are covered in week 7. Their value is concrete: a county publishing a WMS endpoint can be consumed in QGIS, ArcGIS, Leaflet and OpenLayers with no bespoke work by anyone. Standards are what stop every dataset needing its own client.

Practical 1 — Take a web map apart
Core everyone
  1. Compute the tile index for the JKUAT main campus at zoom levels 10, 14 and 17. Do it by hand with a calculator first. Then fetch each tile in the browser and confirm you got what you expected.
  2. Open OpenStreetMap with the Network tab recording. Pan and zoom. Record: how many tile requests per pan, the size of a typical tile, and the total transferred for one screen.
  3. Repeat on Google Maps. The traffic looks different — describe how, and infer what is being delivered.
  4. Calculate how many tiles cover Kenya at zoom 14, given Kenya's bounding box. At an average 15 kB per tile, what would the full set weigh?
  5. Find a Kenyan government or NGO web map. Using devtools alone, work out what technology it is built with and where its data comes from. Write half a page.
Extension if you have time
  1. Write a function converting longitude/latitude/zoom to tile x/y, and its inverse returning the tile's bounding box. Verify the round trip.
  2. Compute the ground resolution in metres per pixel at the equator for zoom 0 to 20 and tabulate it. At which zoom does one pixel first represent less than one metre?
Self-test
  1. Why does the tile scheme make web maps cheap to serve at scale?
  2. How many tiles exist at zoom 15? Show the calculation.
  3. A colleague computes county areas from a Web Mercator layer and reports them in km². What is wrong, and does it matter more for Turkana or for Nunavut?
  4. Give one case each where raster tiles, vector tiles and GeoJSON is the right choice.
  5. In the tile scheme, does y increase northward or southward? Why is that worth remembering?

Reading. The OpenStreetMap wiki article "Slippy map tilenames" — the authoritative statement of the tile maths, with reference implementations. Peterson, Mapping in the Cloud, ch. 1–3. The OGC's own overview of its standards baseline.

02

Week two

Web fundamentals and GeoJSON

By the end of this week you should be able to
  • Write HTML structure and CSS layout sufficient for a map page
  • Use JavaScript's core constructs, and explain how it differs from Python where it matters
  • Read and write GeoJSON by hand, correctly, including coordinate order
  • Fetch remote JSON asynchronously and handle the response
  • Read an HTTP exchange in developer tools and diagnose a failed request

JavaScript for people who know Python

The syntax is learnable in an afternoon. What causes trouble is the handful of places where JavaScript behaves differently from what your Python instincts predict.

ConceptPythonJavaScript
Block structureIndentationBraces; indentation is decorative
Variablesx = 5const if it never changes, let if it does. Never var.
Dictionariesd["key"]obj.key or obj["key"]
Equality=====, always. == coerces types and will hurt you.
String formattingf-stringsTemplate literals with backticks and ${...}
Waiting for I/OBlocks by defaultNever blocks. Callbacks, promises, async/await.

The last row is the one that matters, and it is not a syntax difference — it is a difference in how the language runs. Python executes a line, waits for it to finish, moves on. JavaScript in the browser must never wait, because waiting would freeze the page. So an operation that takes time — fetching a file over the network — returns immediately with a promise of a future result, and your code says what to do when it arrives.

The bug every student writes in week 3

You fetch some GeoJSON, then on the next line try to use it, and it is undefined. Nothing is broken: the fetch has not finished. Your next line ran immediately, because JavaScript did not wait.

The fix is to put the work inside the response handler — either .then(data => { ... }) or by marking the function async and writing await. Once you have seen this once and understood it, you have understood asynchrony, and most of what makes browser JavaScript feel strange goes away.

JavaScript
// WRONG — data is undefined, because fetch has not finished
let data;
fetch("facilities.geojson").then(r => r.json()).then(d => { data = d; });
console.log(data.features.length);          // TypeError

// RIGHT — do the work inside the handler
fetch("facilities.geojson")
  .then(r => {
    if (!r.ok) throw new Error(`HTTP ${r.status}`);   // fetch does NOT throw on 404
    return r.json();
  })
  .then(data => { console.log(data.features.length); })
  .catch(err => console.error("failed to load:", err));

// RIGHT — the same thing, more readable
async function load() {
  const r = await fetch("facilities.geojson");
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
  const data = await r.json();
  console.log(data.features.length);
}

HTTP, in the amount you need

Every piece of data on your map arrives through an HTTP request. Four things to know:

GeoJSON, properly

GeoJSON (RFC 7946) is the interchange format of the web mapping world. It is JSON with a specified structure.

GeoJSON
{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [36.8219, -1.2921]        // [longitude, latitude] — in that order
      },
      "properties": {
        "name": "Kenyatta National Hospital",
        "level": 6,
        "beds": 1800
      }
    }
  ]
}

Seven rules that the specification actually imposes, and that people actually break:

  1. Coordinates are [longitude, latitude]. X before Y. This is the opposite of how you say it aloud and the opposite of Leaflet's own L.latLng.
  2. The CRS is always WGS84 (CRS84, equivalent to EPSG:4326). RFC 7946 removed the ability to declare any other. If your data is in UTM, transform it before writing GeoJSON — a file with UTM coordinates in it is not GeoJSON, whatever the extension says.
  3. Seven geometry types: Point, LineString, Polygon, MultiPoint, MultiLineString, MultiPolygon, GeometryCollection.
  4. Polygon rings must close — the last coordinate pair repeats the first.
  5. Ring winding order: exterior rings counter-clockwise, interior rings (holes) clockwise, by the right-hand rule.
  6. No comments, no trailing commas, no NaN. It is JSON, and JSON has none of these.
  7. A geometry may be null — a valid Feature with attributes and no location.
Longitude first. Except in Leaflet, where it is latitude first.

GeoJSON writes [36.8219, -1.2921]. Leaflet's L.marker and L.latLng take [-1.2921, 36.8219]. Both are internally consistent; the two conventions simply disagree, and you must hold both in your head.

The symptom of getting it wrong is unmistakable and worth memorising: your Kenyan data appears in the Indian Ocean off Somalia, because you have plotted (36.8°N, −1.3°E) instead of (1.3°S, 36.8°E). If a point lands in the sea, check the coordinate order before you check anything else. Note that L.geoJSON() does the swap for you automatically — so this bites specifically when you hand-build markers from parsed coordinates.

Practical 2 — GeoJSON by hand
Core everyone
  1. Write, by hand in a text editor, a GeoJSON FeatureCollection containing five JKUAT campus features: at least one Point, one LineString and one Polygon, each with two or more properties. No tools, no export — type it.
  2. Validate it at geojson.io. Fix every error until it renders where you expect. If the polygon does not close, or the coordinates are reversed, you will see it immediately.
  3. Deliberately break it four ways — reverse a coordinate pair, omit the closing point of the ring, add a trailing comma, put a UTM coordinate in. Record the error message each produces. You are building a mental index of symptoms.
  4. Build a page with a full-height map container using flexbox, and confirm it fills the viewport on both a laptop and a phone.
  5. Write JavaScript that fetches your GeoJSON, counts the features, and writes the count into the page. Handle the 404 case with a message a user could understand.
  6. With the Network tab open, load your page and record the size and timing of every request.
Extension if you have time
  1. Write a validator that checks a FeatureCollection for closed rings, correct winding order, and coordinates within Kenya's bounding box. Report line numbers.
  2. Convert a shapefile to GeoJSON with ogr2ogr, then compare the file sizes and explain the difference.
Self-test
  1. Why does console.log(data) immediately after a fetch print undefined?
  2. What coordinate order does GeoJSON use, and what does Leaflet's L.marker use?
  3. Your points plot in the Indian Ocean. Give the most likely cause and the check that confirms it.
  4. Distinguish HTTP 401 from 403, and 400 from 500. Which side do you debug in each case?
  5. May a GeoJSON file declare EPSG:32737? What should you do if your data is in that CRS?

Reading. RFC 7946, the GeoJSON specification — short, readable, and worth reading in full. MDN's "JavaScript first steps" and "Using Fetch". Eloquent JavaScript ch. 1–5 (free online) if you need the language properly rather than quickly.

03

Week three

Leaflet I — making a map

By the end of this week you should be able to
  • Build a working Leaflet map from an empty file
  • Add tile layers, and meet the attribution obligations that come with them
  • Add markers, popups and tooltips, and style them
  • Load GeoJSON with onEachFeature and pointToLayer
  • Respond to map and layer events

The smallest working map

Leaflet needs three things: a CSS file, a JavaScript file, and a <div> that has a height. The third catches everyone.

HTML
<link rel="stylesheet"
      href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>

<style>
  html, body { height: 100%; margin: 0; }
  #map { height: 100%; }        /* without a height, the map is invisible */
</style>

<div id="map"></div>

<script>
  // note the order: LATITUDE first in Leaflet
  const map = L.map("map").setView([-1.0996, 37.0134], 15);   // JKUAT main campus

  L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
    maxZoom: 19,
    attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
  }).addTo(map);
</script>
A blank grey box is almost always one of four things

The div has no height. Leaflet cannot infer one, and a div with no content is zero pixels tall. Give it an explicit height in CSS.
The script runs before the div exists. Put your script after the div, or wrap it in a load handler.
You opened the file with file://. Serve it over HTTP.
You forgot the Leaflet CSS. The map works, but every tile is stacked at the origin, which looks like chaos rather than a grey box.
Check the Console before you change anything. It usually names the problem outright.

Attribution is not optional

OpenStreetMap data is licensed under the ODbL, which requires attribution — the credit line in the corner is a licence condition, not decoration. Removing it is a licence breach. Some tile providers additionally cap usage: OSM's own tile servers are for development and low-volume use only, and a deployed application sending them significant traffic will be blocked. For anything real, use a provider with terms you have read — Carto, Stadia, MapTiler, or your own tile server. This comes up in week 10.

Markers, popups and tooltips

A marker is a point with an icon. A popup opens on click and holds arbitrary HTML. A tooltip appears on hover and should be short. The default marker icon is a blue pin; a custom icon needs its size and, critically, its anchor — the pixel within the image that sits on the geographic point. Get the anchor wrong and every marker is offset by half an icon, which looks fine on its own and is wrong everywhere.

JavaScript
const clinicIcon = L.icon({
  iconUrl:     "icons/clinic.png",
  iconSize:    [32, 32],
  iconAnchor:  [16, 32],    // bottom centre — the tip of the pin
  popupAnchor: [0, -32]     // popup opens above the icon, not over it
});

L.marker([-1.0996, 37.0134], { icon: clinicIcon })
  .addTo(map)
  .bindPopup("<strong>JKUAT Health Centre</strong><br>Level 3 · Open 24 h")
  .bindTooltip("JKUAT Health Centre");

GeoJSON layers — where the real work happens

L.geoJSON() takes a FeatureCollection and turns it into a layer. It also handles the coordinate-order swap for you. Four options carry almost all of its power:

JavaScript
const res = await fetch("facilities.geojson");
const data = await res.json();

const layer = L.geoJSON(data, {

  filter: f => f.properties.operational === true,

  pointToLayer: (f, latlng) => L.circleMarker(latlng, {
    radius: Math.sqrt(f.properties.beds) / 2,   // area ∝ beds, not radius ∝ beds
    fillColor: "#0E6E78",
    color: "#ffffff",
    weight: 1,
    fillOpacity: 0.75
  }),

  onEachFeature: (f, lyr) => {
    const p = f.properties;
    lyr.bindPopup(`<strong>${p.name}</strong><br>Level ${p.level} · ${p.beds} beds`);
    lyr.on("mouseover", e => e.target.setStyle({ weight: 3 }));
    lyr.on("mouseout",  e => e.target.setStyle({ weight: 1 }));
  }

}).addTo(map);

map.fitBounds(layer.getBounds(), { padding: [20, 20] });   // let the data set the view

Two details in that code are worth dwelling on. fitBounds lets the data decide the view rather than a hard-coded setView — so the map still works when the dataset changes. And the radius uses a square root: human perception reads circle area, so scaling the radius directly by the value makes a facility with four times the beds look sixteen times bigger. Getting this wrong is one of the most common quantitative errors in thematic mapping.

Practical 3 — A facility map for a county
Core everyone
  1. From an empty file, with only the Leaflet documentation open. No template, no AI assistant, no copying from these notes. Build a working map centred on your home county. You will do this once; it is what makes the rest of the unit make sense.
  2. Add two base layers — OSM and one satellite or terrain source — and a layer control to switch between them. Attribute both correctly.
  3. Convert the supplied health-facility CSV to GeoJSON. Load it with L.geoJSON.
  4. Render facilities as circle markers sized by bed count using a square-root scale, and coloured by facility level.
  5. Bind a popup showing name, level, beds and ownership. Bind a tooltip showing just the name.
  6. Use fitBounds so the map opens framed on the data.
  7. Add a click handler on the map that shows the clicked coordinates, to four decimal places, in a corner of the page.
Extension if you have time
  1. Add a search box filtering facilities by name as the user types, updating the map live.
  2. Add a control that reports how many facilities are currently within the map view, updating on every moveend.
Self-test
  1. Your map is a blank grey box. List your first four checks, in order.
  2. What does iconAnchor control, and what is the symptom of getting it wrong?
  3. Why scale circle radius by the square root of the value?
  4. Which L.geoJSON option would you use to bind a popup, and which to change a polygon's fill?
  5. Why may you not remove the attribution control from a map using OSM tiles?

Reading. The Leaflet quick-start guide and the "Using GeoJSON with Leaflet" tutorial — this week's primary text. The full API reference, skimmed once so you know what exists.

04

Week four

Leaflet II — thematic cartography

By the end of this week you should be able to
  • Build a choropleth with a defensible classification
  • Explain how the classification scheme changes the map's message
  • Normalise data correctly and say why counts are usually the wrong variable
  • Choose colour schemes that survive colour-vision deficiency
  • Build a legend, and make a map usable by keyboard

Counts are almost always the wrong variable

Start here, because it is the error that invalidates whole maps. A choropleth colours areas by a value. If that value is a raw count, the map shows you where the people are, not where the phenomenon is. A map of the absolute number of health facilities per county is, to a first approximation, a map of county population — and it will be read as a map of health provision, which is a different and false claim.

Normalise: facilities per 100,000 people, cases per 1,000, hectares per km². The choropleth is a map of intensity, and intensity requires a denominator. There is exactly one exception — mapping a total that genuinely is the quantity of interest, such as total budget allocated — and even then a proportional symbol is usually the better form.

Classification changes the message

Having chosen a variable, you must choose how to cut it into classes. This is not a technical detail; it determines what the reader concludes.

Worked example — the same data, two honest maps

Health facilities per 100,000 population, ten counties:

CountyManderaTurkanaKilifiMeruMachakosKisumuMombasaNakuruKiambuNairobi
Rate571217192122242834

Equal interval, 5 classes. Range 5–34, so class width 5.8:

5.0–10.8 → Mandera, Turkana (2)
10.8–16.6 → Kilifi (1)
16.6–22.4 → Meru, Machakos, Kisumu, Mombasa (4)
22.4–28.2 → Nakuru, Kiambu (2)
28.2–34.0 → Nairobi (1)

Quantile, 5 classes, two counties each:

Mandera, Turkana │ Kilifi, Meru │ Machakos, Kisumu │ Mombasa, Nakuru │ Kiambu, Nairobi

Read the difference. Under equal interval, four counties share the middle class — the centre of the distribution looks flat and homogeneous — and Nairobi stands alone at the top as a visible outlier, which is what the numbers actually say. Under quantile, every class holds exactly two counties, so the middle counties are spread across three shades and look far more differentiated than a 3-point spread warrants; Nairobi loses its distinctness by sharing a class with Kiambu.

Neither map is dishonest. They answer different questions: equal interval preserves the true numeric gaps, quantile guarantees visual contrast. What is dishonest is producing both, choosing the one that supports your preferred conclusion, and not saying so.

SchemeWhat it doesUse when
Equal intervalEqual-width classes across the rangeThe data are roughly uniform; the reader needs true magnitudes
QuantileEqual count of areas per classYou need guaranteed visual contrast; ranking matters more than magnitude
Natural breaks (Jenks)Minimises within-class varianceThe data cluster naturally; the default good general choice
Standard deviationClasses in σ from the meanEmphasising departure from average; needs a diverging scheme
ManualBreaks you chooseThere are meaningful external thresholds — a guideline value, a poverty line

Manual is underrated. If the WHO guideline is 1.5 mg/L, put a class break at 1.5. A statistically optimal break at 1.43 tells the reader nothing; the guideline tells them everything.

Colour

Use ColorBrewer rather than choosing colours by eye — the schemes are perceptually ordered and tested. Around 8% of men have a red–green colour vision deficiency, so a red-to-green diverging scheme is unreadable to roughly one in twelve of your male users; use ColorBrewer's colourblind-safe schemes, such as brown–blue-green or purple–green. And check your map in greyscale: if the classes are still distinguishable, lightness is doing the work and the map will survive both photocopying and colour blindness.

Five to seven classes is the practical maximum. Beyond that readers cannot match a polygon to a legend swatch reliably, and the extra precision is illusory.

JavaScript
// Manual breaks — chosen, documented, defensible
const BREAKS = [0, 10, 15, 20, 25];
const COLORS = ["#edf8fb", "#b2e2e2", "#66c2a4", "#2ca25f", "#006d2c"];

function colorFor(rate) {
  if (rate == null) return "#cccccc";        // no data ≠ zero — show it differently
  for (let i = BREAKS.length - 1; i >= 0; i--) {
    if (rate >= BREAKS[i]) return COLORS[i];
  }
}

L.geoJSON(counties, {
  style: f => ({
    fillColor: colorFor(f.properties.fac_per_100k),
    weight: 1, color: "#ffffff", fillOpacity: 0.85
  })
}).addTo(map);

// --- legend, built from the same arrays so it can never disagree ------
const legend = L.control({ position: "bottomright" });
legend.onAdd = () => {
  const div = L.DomUtil.create("div", "legend");
  div.innerHTML = "<h4>Facilities per 100,000</h4>";
  BREAKS.forEach((b, i) => {
    const hi = BREAKS[i + 1];
    div.innerHTML += `<i style="background:${COLORS[i]}"></i> ${b}${hi ? "–" + hi : "+"}<br>`;
  });
  return div;
};
legend.addTo(map);
Missing data is not zero

A county with no reported figure must not be shaded as though its rate were zero. Give it a distinct neutral grey, label it "no data" in the legend, and say in the caption how many areas are affected. Mapping absent data as low data has caused real misallocation of real resources — the areas with the worst reporting are frequently the areas with the greatest need, so the error runs in the most damaging possible direction.

Accessibility, briefly and seriously

A map built only for a mouse excludes people who navigate by keyboard, and a map whose only information channel is colour excludes people who cannot distinguish those colours. Three things cost you almost nothing:

Practical 4 — A choropleth you can defend
Core everyone
  1. Join the county boundary GeoJSON to the facility and population tables. Compute facilities per 100,000.
  2. Map the raw counts first. Then map the rate. Put them side by side and write two sentences on how the reader's conclusion changes.
  3. Implement all three of equal interval, quantile and natural breaks. Add a control to switch between them live.
  4. Screenshot all three and write a paragraph on which you would publish and why. This paragraph is the assessed part.
  5. Build a legend generated from the same break array as the styling, so the two cannot drift apart.
  6. Handle counties with missing data explicitly, in both the map and the legend.
  7. Add hover highlighting and a click popup showing county name, count, population and rate.
  8. Check the map in a colour-blindness simulator and in greyscale. Fix what fails.
Extension if you have time
  1. Add a bivariate choropleth — two variables on a 3×3 colour grid — with a matching two-dimensional legend. Then write 100 words on whether it communicated better than two separate maps.
  2. Add a linked histogram showing the distribution with the current class breaks drawn on it, updating when the scheme changes.
Self-test
  1. Why is a choropleth of raw counts usually misleading? Give the exception.
  2. Under what circumstance does quantile classification exaggerate differences, and when is that acceptable?
  3. Why is a red-to-green diverging scheme a poor choice, and what would you use instead?
  4. How should a county with no data be shown, and why does the direction of this error matter?
  5. Name two things that make a web map usable without a mouse.

Reading. Muehlenhaus, Web Cartography, ch. 4–6. Brewer, Designing Better Maps, on classification and colour. The ColorBrewer 2.0 tool. Monmonier, How to Lie with Maps, ch. 3 — short, and it will change how you read every choropleth you see afterwards.

05

Week five · CAT 1 this week

Spatial databases with PostGIS

By the end of this week you should be able to
  • Say why a database beats a folder of shapefiles for a web application
  • Load spatial data with ogr2ogr and set the SRID correctly
  • Distinguish geometry from geography and choose between them
  • Write spatial SQL using the core predicate and measurement functions
  • Create a spatial index and demonstrate its effect with EXPLAIN ANALYZE

Why not just serve files?

You have been loading GeoJSON files. For a static map of a few hundred features that is entirely correct. It stops working when any of the following becomes true, and in a real application all of them do:

PostGIS extends PostgreSQL with spatial types, several hundred spatial functions, and spatial indexing. It is the standard open-source spatial database and the back end of a large share of the world's public-sector GIS.

Getting data in

Shell
createdb webmap
psql -d webmap -c "CREATE EXTENSION postgis;"

# load a shapefile, reprojecting to WGS84 on the way in
ogr2ogr -f "PostgreSQL" PG:"dbname=webmap user=postgres" counties.shp \
  -nln counties -nlt PROMOTE_TO_MULTI \
  -t_srs EPSG:4326 -lco GEOMETRY_NAME=geom -lco FID=id

# load a CSV of points
ogr2ogr -f "PostgreSQL" PG:"dbname=webmap user=postgres" facilities.csv \
  -nln facilities -oo X_POSSIBLE_NAMES=lon -oo Y_POSSIBLE_NAMES=lat \
  -a_srs EPSG:4326 -lco GEOMETRY_NAME=geom

Note -t_srs (transform to) against -a_srs (assign, without transforming). Use -a_srs only when the data is already in that CRS but is not labelled. Using it when the data is in a different CRS silently mislabels everything, and nothing will tell you.

geometry or geography?

geometrygeography
ModelFlat planeSphere/spheroid
UnitsWhatever the SRID uses — degrees for 4326Always metres
SpeedFastSlower
FunctionsAll of themA useful subset
Use forData in a projected CRS; heavy analysisGlobal lon/lat data where you want correct metres cheaply

The practical pattern: store as geometry with SRID 4326 because that is what the web wants, and either cast to geography or transform to UTM whenever you measure.

Worked example — the square-degree disaster

Nairobi County has an area of roughly 696 km². Here is what three queries return.

SQL
-- WRONG: geometry is in EPSG:4326, so this is SQUARE DEGREES
SELECT name, ST_Area(geom) FROM counties WHERE name = 'Nairobi';
--  Nairobi | 0.0565          ← a number, with no meaning

-- RIGHT: project to UTM 37S, divide to km²
SELECT name, ST_Area(ST_Transform(geom, 32737)) / 1e6 FROM counties
WHERE name = 'Nairobi';
--  Nairobi | 696.1           ← km²

-- ALSO RIGHT: cast to geography, which always works in metres
SELECT name, ST_Area(geom::geography) / 1e6 FROM counties
WHERE name = 'Nairobi';
--  Nairobi | 696.0

The first query does not fail. It returns a plausible-looking decimal number that a student will paste into a report. Distance behaves identically: ST_Distance between Nairobi and Mombasa in EPSG:4326 returns 3.96 — the Pythagorean distance in degrees — while the same query cast to geography returns 438,900 metres, which is the real answer.

This is the single most consequential error in spatial SQL, and it is invisible: no error, no warning, just a wrong number. Rule: if a query contains ST_Area, ST_Length, ST_Distance or ST_Buffer, it must also contain ST_Transform or ::geography. Make this automatic.

The core function set

FunctionReturns
ST_Intersects(a, b)true if they share any point — the workhorse predicate
ST_Within(a, b) / ST_Containstrue if a is entirely inside b, and the converse
ST_DWithin(a, b, d)true if within distance d — index-aware, unlike ST_Distance(...) < d
ST_Distance(a, b)Shortest distance, in the units of the CRS
ST_Buffer(g, d)A polygon d units around g
ST_Transform(g, srid)Reprojected geometry
ST_Centroid(g)Centre of mass — may fall outside a concave polygon
ST_MakeValid(g)Repairs self-intersections, which real data is full of
ST_AsGeoJSON(g)GeoJSON geometry as text — the bridge to your map

Indexes, and the difference they make

Without an index, a spatial query compares your search geometry against every row in the table. A GiST index stores each geometry's bounding box in a tree, so the database can eliminate most rows before doing any real geometric work.

SQL
CREATE INDEX facilities_geom_idx ON facilities USING GIST (geom);
ANALYZE facilities;                -- update the planner's statistics

-- prove it worked: run this before and after creating the index
EXPLAIN ANALYZE
SELECT f.name
FROM facilities f JOIN counties c ON ST_Intersects(f.geom, c.geom)
WHERE c.name = 'Kiambu';
-- before: Seq Scan on facilities
-- after:  Index Scan using facilities_geom_idx
Two ways to accidentally disable your index

Wrapping the indexed column in a function. WHERE ST_Transform(geom, 32737) && box cannot use the index on geom, because the index knows nothing about the transformed values. Transform the other side of the comparison instead, or build a functional index.

Using ST_Distance(a, b) < 5000 instead of ST_DWithin(a, b, 5000). The first computes an exact distance for every row and then filters; the second uses the index to eliminate rows first. They return the same answer, and on a large table one takes milliseconds and the other takes minutes.

Practical 5 — Answer real questions in SQL
Core everyone
  1. Create the database, enable PostGIS, load counties, facilities, roads and population.
  2. Confirm every table's SRID with ST_SRID. Fix anything reporting 0.
  3. Answer these five in SQL, each in one statement:
    • How many facilities are in each county, ordered descending?
    • Which facilities are within 5 km of Thika town centre? Use ST_DWithin.
    • What is the area of each county in km²?
    • Which county contains the JKUAT main campus?
    • What is the total length of classified road in Kiambu, in km?
  4. For the area query, run it first without transforming. Record the wrong number alongside the right one in your submission — you are building the reflex to notice.
  5. Create GiST indexes. Run EXPLAIN ANALYZE before and after on the county-join query and record both plans and both timings.
  6. Run ST_IsValid across the county table. Repair any failures with ST_MakeValid and report what was wrong.
Extension if you have time
  1. For every facility, find its nearest neighbour and the distance, using the <-> operator with a lateral join.
  2. Compute, per county, the share of population living within 5 km of a level-4-or-higher facility. State your assumptions, because this question hides several.
Self-test
  1. Give three things a database does for a web map that a folder of GeoJSON cannot.
  2. ST_Area(geom) on a 4326 layer returns 0.0565. What is that number and what should you have written?
  3. When would you choose geography over geometry?
  4. Why is ST_DWithin(a, b, 5000) preferable to ST_Distance(a, b) < 5000?
  5. What is the difference between -a_srs and -t_srs in ogr2ogr, and what happens if you confuse them?

Reading. Obe & Hsu, PostGIS in Action, 3rd ed., ch. 1–5. The PostGIS manual's function reference — learn to navigate it; it is the text you will still be using in ten years. The Crunchy Data "Introduction to PostGIS" workshop is free and excellent.

06

Week six

Serving data out of the database

By the end of this week you should be able to
  • Write spatial joins and aggregations that answer a map's question
  • Return GeoJSON directly from SQL
  • Build a minimal Flask endpoint and consume it from Leaflet
  • Keep payloads small through simplification, bounding-box filters and column selection
  • Describe the shape of a well-designed spatial API

The architecture you are building

Browser Flask API PostGIS Tile server Leaflet your code SQL + GiST GeoServer / OSM GET /api/… GeoJSON SQL rows tiles Base map and data travel by separate paths — and fail independently
Base map and data are two different journeys. When your map shows terrain but no points, the API path has failed; when it shows points on a grey void, the tile path has. Knowing which arrow broke is most of the diagnosis, and the Network tab tells you in seconds.

GeoJSON straight from SQL

PostGIS can assemble a complete FeatureCollection in the database, so your API becomes a thin pipe rather than a place where objects are laboriously rebuilt in Python.

SQL
SELECT json_build_object(
  'type',     'FeatureCollection',
  'features', COALESCE(json_agg(feature), '[]'::json)   -- [] not null when empty
)
FROM (
  SELECT json_build_object(
    'type',       'Feature',
    'geometry',   ST_AsGeoJSON(geom)::json,
    'properties', json_build_object('id', id, 'name', name, 'beds', beds)
  ) AS feature
  FROM facilities
  WHERE geom && ST_MakeEnvelope(%s, %s, %s, %s, 4326)   -- bbox, index-aware
  LIMIT 5000
) f;

Two things there earn their place. COALESCE(..., '[]') means an empty result is a valid empty FeatureCollection rather than null, which would crash your JavaScript. And the && bounding-box operator uses the GiST index, so a request for the current map view touches only the rows in view — which is what makes the whole approach scale.

A minimal API

Python
from flask import Flask, jsonify, request, abort
import psycopg2, psycopg2.extras

app = Flask(__name__)

@app.route("/api/facilities")
def facilities():
    try:
        bbox = [float(v) for v in request.args["bbox"].split(",")]
        assert len(bbox) == 4
    except (KeyError, ValueError, AssertionError):
        abort(400, "bbox required as minx,miny,maxx,maxy")

    with psycopg2.connect(DSN) as conn, conn.cursor() as cur:
        cur.execute(SQL, bbox)          # parameterised — NEVER f-string user input
        return jsonify(cur.fetchone()[0])
JavaScript
let layer = L.geoJSON(null).addTo(map);

async function refresh() {
  const b = map.getBounds();
  const bbox = [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()].join(",");
  const r = await fetch(`/api/facilities?bbox=${bbox}`);
  if (!r.ok) return console.error("API error", r.status);
  layer.clearLayers().addData(await r.json());
}

map.on("moveend", refresh);   // moveend, not move — one request per pan, not hundreds
refresh();

Binding to moveend rather than move is the difference between one request per pan and several hundred. On a laboratory network you might not notice; on a phone you will.

Keeping payloads small

Practical 6 — Connect the map to the database
Core everyone
  1. Write SQL returning a valid FeatureCollection of facilities. Validate the output at geojson.io.
  2. Write a spatial join returning facility counts and per-100,000 rates by county, as GeoJSON with the county geometry.
  3. Build a Flask API with three endpoints: facilities filtered by bbox, county summaries, and one facility by id. Return 404 for a missing id and 400 for a malformed bbox.
  4. Rewrite week 4's map to load from the API instead of a static file. Refresh on moveend.
  5. Measure the payload for one screen at zoom 8 and at zoom 14. Then add column selection, precision reduction and zoom-dependent ST_Simplify, and measure again. Report the reduction as a percentage.
  6. Add a loading indicator and a visible, human-readable error message when the API fails. Test it by stopping the server.
Extension if you have time
  1. Add an endpoint returning the n nearest facilities to a clicked point using the <-> operator, and wire it to a map click.
  2. Add cursor-based pagination and an ETag so unchanged responses return 304.
Self-test
  1. Why build the GeoJSON in SQL rather than in Python?
  2. What does COALESCE(json_agg(...), '[]') protect against?
  3. Why bind to moveend rather than move?
  4. Name four ways to reduce the size of a GeoJSON response.
  5. Why ST_SimplifyPreserveTopology rather than ST_Simplify for administrative boundaries?

Reading. Obe & Hsu, ch. 9 on PostGIS output functions. The Flask quickstart. Crunchy Data's writing on serving GeoJSON and vector tiles directly from PostgreSQL.

07

Week seven

GeoServer and the OGC services

By the end of this week you should be able to
  • Publish a PostGIS table as an OGC service
  • Distinguish WMS, WFS and WMTS and choose between them
  • Read a GetCapabilities document
  • Style a layer with SLD
  • Diagnose a CORS failure

What GeoServer is for

Last week you wrote your own API. It works, and for a bespoke application it is often the right choice. But it is yours — nobody else's software knows how to talk to it. GeoServer instead speaks the OGC standards, which means that the moment you publish a layer, it can be consumed by QGIS, ArcGIS, OpenLayers, Leaflet and a dozen other clients with no further work by anyone. That interoperability is the entire value proposition, and it is why national mapping agencies and county governments run GeoServer rather than bespoke APIs.

The object model

Three services, three jobs

ServiceReturnsUse for
WMSA rendered image of the requested areaComplex cartography, large datasets, styling controlled server-side. The client cannot interrogate the features.
WFSThe actual features, as GML or GeoJSONWhen the client needs the geometry and attributes — to analyse, restyle, or edit. Heavier.
WMTSPre-rendered tiles on a fixed gridBase maps and any layer that changes rarely. Cacheable, so much faster than WMS.

The decision is nearly always: does the browser need the geometry? If it only needs to look at it, WMS or WMTS — the server does the drawing and sends pixels, and a million-feature layer costs the same as a hundred-feature one. If the user must click a feature and get its attributes, or you want to restyle without a server round trip, WFS or your own GeoJSON API.

GetCapabilities

Every OGC service answers a GetCapabilities request with an XML document listing what it offers: layer names, titles, supported coordinate systems, bounding boxes, available formats, and the operations it supports. It is a machine-readable contract, and it is how QGIS populates its layer list when you paste in a WMS URL.

HTTP
# what does this server offer?
http://localhost:8080/geoserver/wms?service=WMS&version=1.3.0&request=GetCapabilities

# a rendered image
http://localhost:8080/geoserver/kiambu/wms?service=WMS&version=1.3.0
  &request=GetMap&layers=kiambu:facilities&styles=
  &bbox=36.6,-1.3,37.2,-0.9&width=768&height=512&crs=EPSG:4326&format=image/png

# the features themselves, as GeoJSON
http://localhost:8080/geoserver/kiambu/wfs?service=WFS&version=2.0.0
  &request=GetFeature&typeName=kiambu:facilities&outputFormat=application/json
  &count=100

Learning to read these URLs is worth an hour. When a layer fails to appear in Leaflet, constructing the equivalent GetMap request by hand and pasting it into the address bar tells you immediately whether the problem is GeoServer or your client code.

JavaScript
L.tileLayer.wms("http://localhost:8080/geoserver/kiambu/wms", {
  layers: "kiambu:facilities",
  format: "image/png",
  transparent: true,          // without this you get an opaque white rectangle
  version: "1.3.0",
  attribution: "Kiambu County"
}).addTo(map);

SLD in one page

Styled Layer Descriptor is the OGC's XML styling language. It is verbose, and it is also a genuine standard that transfers between servers. The structure is a set of rules, each with an optional filter and one or more symbolizers.

XML
<Rule>
  <Name>Level 4 and above</Name>
  <ogc:Filter>
    <ogc:PropertyIsGreaterThanOrEqualTo>
      <ogc:PropertyName>level</ogc:PropertyName>
      <ogc:Literal>4</ogc:Literal>
    </ogc:PropertyIsGreaterThanOrEqualTo>
  </ogc:Filter>
  <PointSymbolizer>
    <Graphic>
      <Mark>
        <WellKnownName>circle</WellKnownName>
        <Fill><CssParameter name="fill">#0E6E78</CssParameter></Fill>
      </Mark>
      <Size>12</Size>
    </Graphic>
  </PointSymbolizer>
</Rule>

Rules can also carry MinScaleDenominator and MaxScaleDenominator, which is how a layer shows only labels at small scales and full symbology at large ones. Writing SLD by hand is tedious; generate a first version by styling the layer in QGIS and exporting as SLD, then edit. That is standard professional practice, not a shortcut.

CORS: why it works on your machine and fails when deployed

WMS layers are loaded as images, and images are exempt from the cross-origin rule — so a WMS layer usually works everywhere. WFS returns JSON, which your JavaScript must read, and reading a cross-origin response requires the server's permission.

If your page is served from example.com and GeoServer runs on gis.example.com:8080, those are different origins, and the browser blocks the WFS response unless GeoServer sends Access-Control-Allow-Origin. It works in the lab because both are on localhost. The symptom is a Console message naming CORS explicitly and a Network tab showing the request succeeded with status 200 — the data arrived, and the browser refused to hand it to your code. The fix is on the server: enable GeoServer's CORS filter, or put both behind one reverse proxy so there is only one origin.

Caching

GeoServer bundles GeoWebCache, which stores rendered tiles so that repeat requests are served as files rather than re-rendered. For any layer that changes less often than it is viewed — which is nearly all base data — this is the difference between a map that feels instant and one that does not. Seed the cache for the zoom levels you actually serve, and remember to invalidate it when the underlying data changes, because a stale tile cache is a genuinely confusing bug: the database is right and the map is wrong.

Practical 7 — Publish and consume
Core everyone
  1. Install GeoServer, create a workspace, and connect a PostGIS store to last week's database.
  2. Publish counties and facilities as layers. Set the bounding boxes and the declared SRS correctly.
  3. Style facilities in QGIS by level, export as SLD, upload, and apply. Then hand-edit the SLD to add a scale-dependent rule that hides labels below zoom 12.
  4. Fetch the GetCapabilities document. Find and record: the layer's name, its bounding box, its supported CRS list, and the formats offered.
  5. Consume the layer in Leaflet as WMS. Then construct the equivalent GetMap URL by hand and open it directly.
  6. Query the same layer as WFS with outputFormat=application/json and load the result with L.geoJSON.
  7. Add the WMS endpoint to QGIS. This is the interoperability point of the whole week — one publication, many clients.
  8. Seed the tile cache for zooms 8–14 and compare first-load timing against the uncached layer.
Extension if you have time
  1. Implement GetFeatureInfo so clicking a WMS layer returns attributes, and compare the user experience with the WFS approach.
  2. Deliberately trigger a CORS failure by serving your page from a different port, then fix it with GeoServer's CORS filter. Document both states with screenshots of the Console.
Self-test
  1. When would you publish WMS rather than WFS? Give the deciding question.
  2. What is in a GetCapabilities document, and who reads it?
  3. Your WMS layer draws an opaque white rectangle over the base map. What did you omit?
  4. Why do WMS layers rarely hit CORS problems while WFS layers often do?
  5. The database is updated but the map still shows old data. Give the most likely cause.

Reading. The GeoServer user manual, sections on data, styling and security. The OGC WMS 1.3.0 and WFS 2.0 specifications — skim the operations, do not read cover to cover. The GeoServer SLD cookbook, which is the fastest route to working styles.

08

Week eight

Applications that collect data

By the end of this week you should be able to
  • Design a CRUD API over spatial data
  • Let users draw and edit geometry in the browser
  • Validate input on both the client and the server, and know why both
  • Write SQL that is not vulnerable to injection
  • Handle secrets so they never reach the browser

From reading to writing

Everything so far has been read-only. The moment users can write, three new problems appear that did not exist before: validation (is this input sane?), authorisation (may this person do this?) and integrity (what happens if two people edit the same feature?). These are what separate an application from a demo.

Map HTTP methods onto operations conventionally, because clients and proxies rely on the conventions — in particular that GET never changes anything, which is why a crawler must never be able to delete your data by following a link:

RouteDoes
GET /api/reportsList, filtered by bbox and query parameters
GET /api/reports/<id>One report, or 404
POST /api/reportsCreate; returns 201 and the new id
PATCH /api/reports/<id>Modify some fields
DELETE /api/reports/<id>Remove; usually a soft delete in practice

Drawing on the map

Leaflet.draw and Leaflet-Geoman both add drawing and editing controls. The workflow is the same: the user draws, you receive a layer, you convert it to GeoJSON, you post it with the form data.

JavaScript
const drawn = new L.FeatureGroup().addTo(map);

map.addControl(new L.Control.Draw({
  edit: { featureGroup: drawn },
  draw: { polygon: true, marker: true, polyline: false,
          circle: false, rectangle: false, circlemarker: false }
}));

map.on(L.Draw.Event.CREATED, e => {
  drawn.clearLayers().addLayer(e.layer);       // one feature at a time
  document.querySelector("#report-form").hidden = false;
});

async function submit(ev) {
  ev.preventDefault();
  const layers = drawn.getLayers();
  if (!layers.length) return showError("Draw the location on the map first.");

  const res = await fetch("/api/reports", {
    method:  "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      geometry:    layers[0].toGeoJSON().geometry,
      category:    form.category.value,
      description: form.description.value
    })
  });

  if (res.status === 201) { drawn.clearLayers(); refresh(); showOk("Report saved."); }
  else showError((await res.json()).error ?? "Could not save the report.");
}

Validate twice

Client-side validation is for the user: fast feedback, no wasted round trip. Server-side validation is for correctness, because anything a browser can send, an attacker can send without using a browser. Disabling a submit button prevents nothing — curl does not run your JavaScript. Every rule that must hold has to be enforced on the server; the client copy is a convenience.

For spatial data specifically, check: the geometry is the type you expected; it is valid (ST_IsValid); it falls inside your area of interest; polygons are not absurdly large or self-intersecting; and the vertex count is bounded, or someone will post a polygon with a million points.

SQL injection, and the one habit that prevents it

Never build SQL by string formatting with values that came from a user. This is not a stylistic preference.

# CATASTROPHIC — the user controls your SQL
cur.execute(f"SELECT * FROM reports WHERE category = '{cat}'")

# A category of:  x' OR '1'='1
#   returns every row, including rows the user should never see.

# CORRECT — the driver sends the value separately from the statement
cur.execute("SELECT * FROM reports WHERE category = %s", (cat,))

With a parameterised query the value can never be parsed as SQL, whatever it contains. The habit to build is simple and absolute: if a value came from outside your program, it goes in the parameter tuple, never in the string. This applies to ORDER BY columns and table names too — those cannot be parameterised, so validate them against an allow-list of permitted names instead.

Secrets

Anything in your JavaScript is public. Not "hard to find" — public: it is delivered to every visitor and readable with two clicks. Database passwords, API keys with billing attached, and admin tokens must live in server-side environment variables, never in client code and never committed to git.

Where a third-party key must be used from the browser — some tile providers require this — restrict it at the provider: lock it to your domain, cap the request rate, and set a spending limit. Treat a leaked key as a bill you will personally receive, because that is how it usually presents.

Authentication, in the amount this unit needs

You are not building a production identity system. You do need to understand the shape: authentication establishes who a user is; authorisation decides what they may do. Store password hashes, never passwords — use a library, never your own hashing. Serve everything over HTTPS, because a session cookie sent over plain HTTP is readable by anyone on the same network. And check authorisation on the server for every write, on every request; hiding a delete button is presentation, not security.

Practical 8 — A field reporting application
Core everyone
  1. Create a reports table with geometry, category, description, reporter, status and timestamp.
  2. Build the five CRUD endpoints. Use parameterised queries throughout. Return correct status codes: 201 on create, 400 on bad input, 404 on missing, 204 on delete.
  3. Add drawing controls so a user can place a point or draw a polygon.
  4. Build a form for the attributes. Validate on the client for usability and on the server for correctness — and make the two rule sets visibly the same rules.
  5. Server-side, reject geometries outside Kenya's bounding box, invalid geometries, and polygons with more than 1,000 vertices. Return a message explaining what was wrong and how to fix it.
  6. Show all reports on the map, coloured by status, refreshing after each submission.
  7. Attack your own application. Try to inject SQL through every text field. Try to POST a malformed geometry with curl. Try to delete a report you did not create. Document what you tried and what happened.
  8. Move the database credentials into environment variables and confirm nothing sensitive appears in the page source.
Extension if you have time
  1. Add login with hashed passwords, and restrict editing to the report's creator.
  2. Add photo upload with a size and type restriction, storing files outside the web root.
  3. Add optimistic concurrency: reject a PATCH whose version does not match the current row, and handle the conflict in the interface.
Self-test
  1. Why is client-side validation insufficient? Give a concrete way to bypass it.
  2. Rewrite cur.execute(f"... WHERE id = {uid}") safely, and explain why yours cannot be injected.
  3. Why must GET never modify data?
  4. A student puts a tile-provider API key in their JavaScript. What is the exposure, and what mitigations exist?
  5. Name three server-side checks specific to user-submitted geometry.

Reading. The OWASP Top Ten — read the injection and access-control entries properly. The Flask documentation on request handling and configuration. Leaflet-Geoman's documentation for the drawing interface.

09

Week nine

Performance and cloud-native geospatial

By the end of this week you should be able to
  • Measure a map's payload and time-to-first-map, and act on the numbers
  • Explain vector tiles and when they beat GeoJSON
  • Describe COG and PMTiles and the range-request idea behind both
  • Apply zoom-dependent generalisation
  • Judge when a tile server is unnecessary

Measure before you optimise

Open the Network tab, hard-reload, and record three numbers: total bytes transferred, number of requests, and time until the map is usable. Then throttle the connection to "Slow 3G" and record them again. That second set is the real number for a large share of your users.

This is not an abstract concern in Kenya. Most users reach your map on a phone, over mobile data bought in bundles. A 40 MB GeoJSON is not merely slow — it consumes a measurable fraction of a monthly bundle to display one screen, and the user pays for the 39 MB they never see. A map that is expensive to open is a map that does not get opened. Payload is a design constraint, not a technicality, and it belongs in your project's requirements alongside the cartography.

SymptomLikely causeFix
Slow first load, one huge requestWhole dataset as GeoJSONBbox filtering, or vector tiles
Fine at low zoom, freezes at highToo many DOM elementsCanvas rendering, clustering, vector tiles
Hundreds of small requestsBound to move not moveend; no cachingDebounce; cache headers
Fast locally, slow deployedNo compression; distant serverEnable gzip/brotli; use a CDN
Boundaries with millions of verticesSurvey-grade data on a web mapZoom-dependent ST_Simplify

Vector tiles

A vector tile is the same tile pyramid idea applied to geometry instead of pixels: each tile holds the features intersecting it, clipped, simplified to that zoom, and encoded compactly as protobuf (the MVT format). The browser draws them.

Compared with GeoJSON: only the visible area is transferred; each zoom gets appropriately generalised geometry; the encoding is far denser than JSON text; and styling stays client-side, so you can restyle instantly without re-fetching. Compared with raster tiles: features remain interactive and restylable, and the tiles are usually smaller.

The cost is complexity — you need something to generate and serve them (tippecanoe to build, pg_tileserv or ST_AsMVT to serve dynamically) and MapLibre GL rather than plain Leaflet to get the full benefit. The rule of thumb: under a few thousand features, GeoJSON with bbox filtering is simpler and fine. Above roughly ten thousand, vector tiles.

SQL
-- vector tiles straight from PostGIS, no tile server needed
WITH bounds AS (SELECT ST_TileEnvelope(%(z)s, %(x)s, %(y)s) AS geom),
     mvtgeom AS (
  SELECT ST_AsMVTGeom(ST_Transform(f.geom, 3857), bounds.geom) AS geom,
         f.id, f.name, f.level
  FROM facilities f, bounds
  WHERE ST_Intersects(ST_Transform(f.geom, 3857), bounds.geom)
)
SELECT ST_AsMVT(mvtgeom, 'facilities') FROM mvtgeom;

Cloud-native: the range request

One idea underlies the whole cloud-native geospatial movement. HTTP lets a client request part of a file — a byte range — rather than the whole thing. So if a file is organised so that its useful pieces sit in known places, a client can read just the pieces it needs, straight from cheap static storage, with no server-side software at all.

When you do not need GeoServer. You have learned it, and it is genuinely the right tool for OGC interoperability, complex server-side styling, and datasets that change constantly.

But if your data is static and you want a fast base map, PMTiles on static hosting will outperform it at a fraction of the operational cost — nothing to patch, nothing to restart, nothing to run out of memory at 3 a.m. Knowing when not to deploy the heavy tool is a professional judgement, and it is one this unit expects you to be able to make and justify.

Practical 9 — Make it fast
Core everyone
  1. Take a large dataset — national roads or all facilities — and load it as a single GeoJSON. Record payload, request count and time to usable map. Then repeat throttled to Slow 3G.
  2. Add bbox filtering. Measure again.
  3. Add zoom-dependent ST_Simplify and coordinate precision reduction. Measure again.
  4. Switch to canvas rendering with preferCanvas: true. Measure interaction smoothness at high zoom with many features.
  5. Serve the same data as vector tiles via ST_AsMVT. Measure again.
  6. Produce one table of all five configurations with all three metrics, and a short paragraph recommending one for a specified user on a specified connection.
  7. Enable gzip on your server and record the change in transferred bytes.
Extension if you have time
  1. Build a PMTiles archive with tippecanoe, host it as a static file, and load it in MapLibre. Compare operational complexity with the GeoServer route.
  2. Load a Sentinel-2 COG from a public bucket and display one band, fetching only the visible tiles.
Self-test
  1. Which three numbers do you record before optimising anything?
  2. Above roughly what feature count would you move from GeoJSON to vector tiles, and why?
  3. Explain the range request and why COG and PMTiles both depend on it.
  4. Your map is fine at zoom 8 and unusable at zoom 15. What is the likely cause and the fix?
  5. Give a concrete case where deploying GeoServer would be the wrong decision.

Reading. The Mapbox Vector Tile specification. The Cloud Optimized GeoTIFF and PMTiles project documentation. Crunchy Data's writing on ST_AsMVT. The Cloud-Native Geospatial Foundation's introductory material.

10

Week ten · CAT 2 this week

Deployment, volunteered data, and ethics

By the end of this week you should be able to
  • Deploy an application to a public URL with HTTPS
  • Explain the OpenStreetMap data model and the obligations of the ODbL
  • Assess the quality of volunteered geographic information
  • Identify locational privacy risks in a mapping project
  • Write a data-ethics statement for your own work

Getting it online

Two routes, and the choice follows from what you built.

On a VPS the pieces are: a WSGI server (gunicorn) running your Flask app; a reverse proxy (nginx) in front of it serving static files and terminating TLS; PostgreSQL; and a certificate from Let's Encrypt via certbot, which is free and automatic. Configuration lives in environment variables, never in the repository. Docker Compose is worth the afternoon it takes to learn, because it turns that whole stack into one file that runs identically on your laptop and the server — which is what makes "it worked on my machine" stop being a sentence you say.

HTTPS is not optional. Browsers restrict features on insecure origins — geolocation, among others, simply does not work over plain HTTP — and any credential sent over HTTP is readable by everyone between the user and your server.

OpenStreetMap

OSM is a free, editable map of the world built by volunteers. Its data model has three primitives and one organising idea:

The ODbL follows your data

OSM data is licensed under the Open Database Licence. Two obligations matter. Attribution — credit OpenStreetMap contributors visibly. Share-alike — if you publish a derived database, you must publish it under the ODbL too.

The consequential subtlety is what counts as "derived". Producing a map image from OSM is a produced work, which needs attribution but not share-alike. Merging OSM geometry into your own database and publishing that is a derivative database, and share-alike bites. Students frequently plan a commercial project on OSM data without noticing that they may be obliged to open their database. Check before you build, not after.

Volunteered geographic information

Goodchild's phrase "citizens as sensors" named a genuine shift: the people who know a place best now have the tools to map it. The results can be extraordinary. Humanitarian OpenStreetMap Team mobilises remote volunteers to trace buildings and roads from imagery within hours of a disaster, and that data has routed real responders. Map Kibera, begun in 2009, is the case worth studying closely: Kibera appeared on official maps as a blank space, and residents mapped their own settlement — schools, clinics, water points, toilets — producing data that was used in service planning and advocacy. The people mapping were the people mapped.

Quality is the standing question. VGI has no formal accuracy specification and no accountable producer. In practice it is uneven in a predictable way: excellent where many contributors are active, thin where few are. Assess it as you would any dataset — check completeness against an independent source, look at edit history and contributor count, compare geometry against recent imagery, and be explicit in your report about what you checked. "It came from OSM" is a provenance statement, not a quality statement.

The ethics, which are not optional in this unit

A map is an intervention. It makes some things visible and leaves others out, and both have consequences for real people. Four issues arise directly in the projects you are building.

Locational privacy

A person's location over time is among the most sensitive data there is. It reveals where they live, work, worship, seek medical care, and who they associate with. Aggregate before publishing, coarsen precision, and think hard before mapping individuals at all. Note that de-identification is weaker than it looks: a "de-identified" set of home locations is trivially re-identifiable, because a home location is an identifier.

Mapping vulnerable people

Publishing precise locations of a group can expose them to targeting. This is not hypothetical: locations of refugees, of people receiving HIV care, of survivors of violence, of undocumented residents, of rhino populations. The rule is to ask who else can read this map and what they might do with it. Sometimes the correct professional decision is a coarser map, or no map.

Consent and benefit

If you map a community, did they agree, and do they gain? The distinction between mapping with a community and mapping it as a subject is the distinction between Map Kibera and extraction. This applies to student projects too: the residents whose settlement you map for coursework are not a dataset.

Absence is a decision

What a map omits shapes what gets funded and served. A settlement absent from official maps is absent from planning. Choosing what to include, and at what resolution, is an exercise of power, and pretending it is a neutral technical choice is itself a choice.

Practical 10 — Deploy, contribute, and account for it
Core everyone
  1. Deploy your week 8 application to a public URL with HTTPS. Confirm it works from a phone on mobile data, not campus wifi.
  2. Move every secret to environment variables. Confirm none appears in the page source or in the repository history.
  3. Write a README that lets a stranger run your project: prerequisites, setup, environment variables, how to load the data, how to start it.
  4. Create an OSM account and make at least three accepted edits in an area you know personally. Add something genuinely missing. Document what you added and why you were confident it was correct.
  5. Assess OSM building coverage for one ward against recent imagery. Estimate completeness as a percentage, and describe your method — including its weaknesses.
  6. Write a one-page data-ethics statement for your own project: what data you hold, whose it is, what could go wrong if it leaked, what you have done about that, and what you would change if the project ran for real.
Extension if you have time
  1. Containerise the stack with Docker Compose and demonstrate a clean deployment on a fresh machine.
  2. Set up automated deployment from your git repository, with the application rebuilt on every push to the main branch.
Self-test
  1. When is static hosting sufficient, and what forces you onto a VPS?
  2. Distinguish an OSM way from a relation, and give a case needing each.
  3. Under the ODbL, when does share-alike apply and when does attribution alone suffice?
  4. You are asked to publish a map of households receiving food aid, at building level. What are your concerns and what do you propose instead?
  5. Give two concrete checks on the quality of an OSM extract.

Reading. Goodchild (2007), "Citizens as sensors", GeoJournal 69(4). Hagen (2011) on Map Kibera, Innovations 6(1). The OSM wiki on the ODbL and on the licence's "produced work" distinction. Crampton, Mapping: A Critical Introduction to Cartography and GIS, ch. 1–3.

11

Week eleven

Build week and critique

By the end of this week you should be able to
  • Present and defend a technical build under questioning
  • Review another developer's code and give useful criticism
  • Document a project so someone else can run and extend it

What a finished project looks like

Not "it runs on my laptop". A finished web mapping project has: a public URL over HTTPS; a README that a stranger can follow to a running instance; data whose source and licence are stated; secrets outside the repository; sensible behaviour when the network is slow or the API is down; and a map that is legible on a phone.

That last one is not a nicety. Test on an actual phone, on actual mobile data. A surprising number of otherwise good student projects are unusable on the device most of their users would have.

Reviewing code

You will review a peer's project and be reviewed. Useful review is specific and about the code, never the person. "This is confusing" is not review; "this function does three things — fetching, transforming and rendering — which makes the transform impossible to test on its own" is.

Work through in this order, because it puts the things that matter first:

  1. Does it run? Follow the README exactly, on a clean machine, and note every place you had to guess.
  2. Is it correct? Are areas and distances computed in a projected CRS? Are rates normalised? Is missing data distinguished from zero?
  3. Is it safe? Parameterised queries? Secrets out of the client? Server-side validation on every write?
  4. Is it usable? On a phone. On a slow connection. With a keyboard. In greyscale.
  5. Is it maintainable? Could you add a layer next month without rereading everything?
Practical 11 — Presentation, defence and review
Format

Ten minutes of live demonstration — the deployed application, not slides and not a video — then five minutes of questions from the class and from me. The reasoning behind your decisions is what is being examined, not the polish of the interface.

You must be able to answer
  • Show me the Network tab. What is the payload for one screen, and what did you do to get it there?
  • Why this classification scheme, and what does the map look like under a different one?
  • Show me a query that measures a distance or an area. Where is the projection handled?
  • Where does user input enter your SQL, and why can it not be injected?
  • What happens if the API is down? Show me.
  • What are the privacy implications of your dataset, and what did you do about them?
  • What would you rebuild differently, and why?
Deliverables
  • The deployed URL.
  • The repository, with a README that works and no secrets in the history.
  • A report of no more than six pages: the problem, the users, the architecture with a diagram, the data and its licences, the cartographic decisions with justification, the performance measurements, the ethics statement, and the known limitations.
  • A written review of your assigned peer's project, following the five-point order above.
  • The AI-use declaration: which tools, which prompts, what they produced, what you changed and why.

What to carry out of this unit

The specific technologies will move. Leaflet may be displaced by MapLibre; GeoServer is already optional for a large class of problems that PMTiles solves more simply. What will not move is the structure underneath: a tile pyramid; a projection with known distortions; a client and a server with a network between them that is slower and more expensive than you think; a database that is better at spatial questions than your application code will ever be; and a user on a phone, paying for every megabyte you send.

And one more thing, which is the reason week 10 is in this unit at all: the map you build will be read as true. That is what maps do. Building them carefully — normalising the right denominator, distinguishing missing from zero, thinking about who is exposed by what you publish — is not extra credit. It is the job.

Reading. Monmonier, How to Lie with Maps, 3rd ed. — the whole book, and it is short. Nielsen's usability heuristics, applied to your own interface.

A

Appendix A

JavaScript and SQL self-check

Your programming unit was in Python. This unit is in JavaScript and SQL. Work through the tasks below before week 2, without help of any kind — no notes, no search, no AI assistant. The point is to find out what you have, and using help defeats it entirely.

Scoring yourself honestly
  • Most of both sets — go straight to the Extension tasks; the Core will not stretch you.
  • Comfortable in one, lost in the other — the usual position, and entirely normal. Spend a weekend on the weaker one using the resources below.
  • Struggling with both — fixable, but not by hoping. See me in week 1. There is a supported route through this unit, and taking it early beats discovering the problem in week 5.

JavaScript — eight tasks

  1. Declare a constant holding an array of five numbers. Print its length.
  2. Write a function that takes an array of numbers and returns their mean. Write it twice — once with a for loop, once with reduce.
  3. Given an array of objects each with name and beds, return an array of just the names of those with more than 100 beds. Use filter and map.
  4. Explain in one sentence what const x = {a: 1}; x.a = 2; does, and why it is not an error.
  5. Write a template literal producing "Nairobi has 34 facilities" from variables county and n.
  6. Explain what this prints, and why:
    console.log("5" == 5, "5" === 5);
  7. Write an async function that fetches JSON from a URL, throws on a non-200 status, and returns the parsed object.
  8. Explain what is wrong here and fix it:
    let d; fetch(u).then(r => r.json()).then(j => d = j); console.log(d.length);

SQL — six tasks

  1. Select the name and population of all counties with population above one million, ordered descending.
  2. Count the rows in facilities grouped by level.
  3. Join facilities to counties on county_id and return county name with facility count. Include counties with zero facilities.
  4. Explain the difference between WHERE and HAVING, with an example of each.
  5. Explain the difference between an INNER JOIN and a LEFT JOIN, and say which task 3 needs.
  6. Explain what is dangerous here and rewrite it safely:
    cur.execute(f"SELECT * FROM users WHERE name = '{n}'")

Tasks JS 1–5 and SQL 1–3 are the floor. JS 6–8 and SQL 4–6 are the working level for this unit — JS 8 is week 2's central concept and SQL 6 is week 8's.

Six things that will actually bite you

Where to go

On using AI assistants in this unit

They are permitted, and declared. Use them for what they are good at — explaining an error message, reminding you of an option name, drafting the boilerplate of a form.

Do not use them for the self-check above, or for the task in practical 3 marked from an empty file. And be aware of the specific way they fail here: generated web mapping code confidently computes areas in EPSG:4326, maps raw counts as choropleths, loads whole datasets client-side, builds SQL by string formatting, and puts API keys in the browser. Every one of those is an error this unit trains you to catch, and every one produces a map that looks completely normal. The tool is only an asset if you can catch them; otherwise it is a liability with a professional-looking interface.

B

Appendix B

Quick reference

Coordinate reference systems you will use

EPSGNameUse for
4326WGS 84, lon/lat degreesStorage and interchange. GeoJSON requires it. Never measure in it.
3857Web MercatorTiles and web display. Never measure in it either.
32736UTM zone 36SMeasurement in Kenya west of 36°E — Kisumu, Kakamega, Eldoret
32737UTM zone 37SMeasurement in Kenya east of 36°E — Nairobi, Thika, Mombasa
21037Arc 1960 / UTM 37SOlder Kenyan survey data. Check the datum before mixing with WGS 84 sources.

HTTP status codes worth recognising

200 / 201 / 204OK · Created · No content (a successful delete)
304Not modified — your cached copy is still good
400 / 404Your request was malformed · Nothing at that address
401 / 403Not authenticated · Authenticated but not permitted
500 / 502 / 504Server error · Bad gateway · Gateway timeout

The errors you will actually hit

SymptomUsual cause
Blank grey boxMap div has no height; or script ran before the div existed
Tiles stacked at top-leftLeaflet CSS not loaded
Data plots in the Indian OceanCoordinate order reversed — lat/lon instead of lon/lat
fetch blocked, file:// in the URLYou opened the HTML by double-clicking. Serve over HTTP.
CORS error, status 200Response arrived; server did not permit your origin to read it
Area or distance is a small decimalComputed in degrees. Transform or cast to geography.
Query fast in dev, slow in productionMissing GiST index, or a function wrapping the indexed column
WMS draws an opaque white boxtransparent: true omitted
Map shows stale data after a database updateTile cache not invalidated
undefined immediately after a fetchThe fetch has not finished. Work inside the handler.

Leaflet, minimum viable

JavaScript
L.map(id, opts)                    // create; opts: center, zoom, preferCanvas
map.setView([lat, lng], zoom)      // LATITUDE FIRST
map.fitBounds(bounds, {padding})   // let the data set the view
L.tileLayer(urlTemplate, {attribution, maxZoom})
L.tileLayer.wms(url, {layers, format, transparent: true, version})
L.marker(latlng, {icon}) · L.circleMarker(latlng, {radius, fillColor})
L.geoJSON(data, {style, pointToLayer, onEachFeature, filter})
layer.bindPopup(html) · layer.bindTooltip(text)
map.on("moveend"|"click"|"zoomend", handler)
L.control.layers(baseLayers, overlays).addTo(map)

PostGIS, minimum viable

SQL
ST_Intersects(a,b) · ST_Within(a,b) · ST_Contains(a,b)
ST_DWithin(a,b,d)                  -- index-aware; prefer over ST_Distance < d
ST_Distance(a,b) · ST_Area(g) · ST_Length(g)     -- units follow the CRS!
ST_Transform(g, srid) · g::geography             -- one of these, every time
ST_Buffer(g,d) · ST_Centroid(g) · ST_Simplify(g,tol)
ST_SimplifyPreserveTopology(g,tol)               -- for adjacent polygons
ST_IsValid(g) · ST_MakeValid(g)
ST_AsGeoJSON(g, precision) · ST_AsMVT(rows, name)
ST_SRID(g) · ST_SetSRID(g, srid) · ST_MakeEnvelope(...)
a && b                             -- bbox overlap; uses the index
a <-> b                            -- distance operator, for KNN with ORDER BY
CREATE INDEX x ON t USING GIST (geom);
C

Appendix C

Data and services

DatasetUsed inNotes
Kenya county boundaries4–7All 47 counties. Supplied simplified and full so you can measure the difference in week 9.
Health facility register3–8Name, level, ownership, beds, coordinates. Real data, with real problems — duplicates, missing coordinates, points in the sea. Cleaning it is part of the exercise.
County population4From the 2019 census. The denominator for every rate you compute.
Classified roads5, 9Large enough that naive GeoJSON delivery fails, which is the point in week 9.
OSM extract, Kenya10From Geofabrik. Used for the quality assessment and the ODbL discussion.
Campus survey data2, 8JKUAT buildings and paths. Small, local, and yours to extend.

Free services you can rely on

OpenStreetMap tilesDevelopment only — read the tile usage policy before you deploy anything
Carto / Stadia / MapTilerFree tiers adequate for coursework; check the attribution requirements
GeofabrikOSM extracts by country and region, updated daily
Overpass APIQuerying OSM directly for specific features
NominatimGeocoding. Rate-limited — respect it, and never use it for bulk geocoding
Netlify / GitHub Pages / Cloudflare PagesFree static hosting with HTTPS for the front end

All datasets are distributed through the unit's shared folder with a README giving CRS, provenance and licence for each. Check the licence before reusing anything outside this unit — the OSM extract carries ODbL obligations that follow it into whatever you build.

EGE 2531 Web Mapping · Course reader, edition 1.0 · Jomo Kenyatta University of Agriculture and Technology · September 2026. Corrections and suggestions are welcome and will be credited in the next edition.