EGE 2531 · Jomo Kenyatta University of Agriculture and Technology
A course reader for undergraduate Geomatic Engineering and Geospatial Information Systems.
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.
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.
Five things, installed in this order. Weeks 1–4 need only the first two.
| What | Why | When |
|---|---|---|
| A code editor — VS Code | With the Live Server extension, which serves your folder over HTTP | week 1 |
| A modern browser — Firefox or Chrome | For the developer tools, which are half of this unit | week 1 |
| PostgreSQL + PostGIS | The spatial database | week 5 |
GDAL — for ogr2ogr | Loading data into the database and converting between formats | week 5 |
| Python + Flask | The API layer. This part is in Python — your existing skills apply | week 6 |
| GeoServer (needs Java) | Publishing OGC services | week 7 |
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.
Week one
You open a web map centred on Nairobi. In the second before it appears, this happens:
<div> and works out which geographic area it must cover at the requested zoom.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.
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:
λ is longitude and φ latitude in radians. The asinh term is the Mercator projection of latitude; everything peculiar about web maps traces back to it.
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.
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).
| Latitude | Area inflation | Where |
|---|---|---|
| 0° | ×1.00 | Nairobi, Kampala, Kisumu |
| 30° | ×1.33 | Cairo |
| 45° | ×2.00 | Milan |
| 60° | ×4.00 | Oslo, Anchorage |
| 75° | ×14.9 | Northern 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.
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.
| Approach | How it works | Use when |
|---|---|---|
| Raster tiles | Pre-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 tiles | Compressed geometry in tiles, styled and drawn by the browser. | Large datasets needing interactivity, restyling or smooth zoom. Week 9. |
| GeoJSON | The 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.
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.
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.
Week two
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.
| Concept | Python | JavaScript |
|---|---|---|
| Block structure | Indentation | Braces; indentation is decorative |
| Variables | x = 5 | const if it never changes, let if it does. Never var. |
| Dictionaries | d["key"] | obj.key or obj["key"] |
| Equality | == | ===, always. == coerces types and will hurt you. |
| String formatting | f-strings | Template literals with backticks and ${...} |
| Waiting for I/O | Blocks by default | Never 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.
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.
// 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);
}
Every piece of data on your map arrives through an HTTP request. Four things to know:
GET retrieves and must not change anything. POST creates. PUT replaces, PATCH modifies, DELETE removes. Week 8 uses all of them.Content-Type says what the body is; the CORS headers decide whether your JavaScript is allowed to read the response.Access-Control-Allow-Origin header. You will meet this in week 7 with GeoServer, and it is the most common cause of a map that works locally and fails when deployed.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:
L.latLng.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.NaN. It is JSON, and JSON has none of these.null — a valid Feature with attributes and no location.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.
ogr2ogr, then compare the file sizes and explain the difference.console.log(data) immediately after a fetch print undefined?L.marker use?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.
Week three
onEachFeature and pointToLayerLeaflet needs three things: a CSS file, a JavaScript file, and a <div> that has a height. The third catches everyone.
<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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
</script>
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.
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.
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.
JavaScriptconst 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");
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:
pointToLayer(feature, latlng) — decides what a Point becomes. Return an L.circleMarker instead of a pin and you can size and colour it by an attribute.style(feature) — returns a style object for lines and polygons. Called once per feature, so it can depend on the data.onEachFeature(feature, layer) — runs once per feature after creation. Bind popups and event handlers here.filter(feature) — return false to drop a feature before it is drawn.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.
L.geoJSON.fitBounds so the map opens framed on the data.moveend.iconAnchor control, and what is the symptom of getting it wrong?L.geoJSON option would you use to bind a popup, and which to change a polygon's fill?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.
Week four
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.
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.
Health facilities per 100,000 population, ten counties:
| County | Mandera | Turkana | Kilifi | Meru | Machakos | Kisumu | Mombasa | Nakuru | Kiambu | Nairobi |
|---|---|---|---|---|---|---|---|---|---|---|
| Rate | 5 | 7 | 12 | 17 | 19 | 21 | 22 | 24 | 28 | 34 |
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.
| Scheme | What it does | Use when |
|---|---|---|
| Equal interval | Equal-width classes across the range | The data are roughly uniform; the reader needs true magnitudes |
| Quantile | Equal count of areas per class | You need guaranteed visual contrast; ranking matters more than magnitude |
| Natural breaks (Jenks) | Minimises within-class variance | The data cluster naturally; the default good general choice |
| Standard deviation | Classes in σ from the mean | Emphasising departure from average; needs a diverging scheme |
| Manual | Breaks you choose | There 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.
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);
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.
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:
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.
Week five · CAT 1 this week
ogr2ogr and set the SRID correctlygeometry from geography and choose between themEXPLAIN ANALYZEYou 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.
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 | geography | |
|---|---|---|
| Model | Flat plane | Sphere/spheroid |
| Units | Whatever the SRID uses — degrees for 4326 | Always metres |
| Speed | Fast | Slower |
| Functions | All of them | A useful subset |
| Use for | Data in a projected CRS; heavy analysis | Global 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.
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.
| Function | Returns |
|---|---|
ST_Intersects(a, b) | true if they share any point — the workhorse predicate |
ST_Within(a, b) / ST_Contains | true 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 |
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.
SQLCREATE 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
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.
ST_SRID. Fix anything reporting 0.ST_DWithin.EXPLAIN ANALYZE before and after on the county-join query and record both plans and both timings.ST_IsValid across the county table. Repair any failures with ST_MakeValid and report what was wrong.<-> operator with a lateral join.ST_Area(geom) on a 4326 layer returns 0.0565. What is that number and what should you have written?geography over geometry?ST_DWithin(a, b, 5000) preferable to ST_Distance(a, b) < 5000?-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.
Week six
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.
SQLSELECT 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.
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.
ST_Simplify with a tolerance scaled to the zoom level: national boundaries at zoom 6 do not need centimetre vertices. Use ST_SimplifyPreserveTopology for polygons, or adjacent counties will develop gaps between them.ST_AsGeoJSON(geom, 5) gives about one metre — plenty for a web map, and it can cut file size by a third against the fifteen decimal places PostGIS emits by default.LIMIT with an honest message beats a request that times out.moveend.ST_Simplify, and measure again. Report the reduction as a percentage.<-> operator, and wire it to a map click.ETag so unchanged responses return 304.COALESCE(json_agg(...), '[]') protect against?moveend rather than move?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.
Week seven
GetCapabilities documentLast 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.
kiambu.| Service | Returns | Use for |
|---|---|---|
| WMS | A rendered image of the requested area | Complex cartography, large datasets, styling controlled server-side. The client cannot interrogate the features. |
| WFS | The actual features, as GML or GeoJSON | When the client needs the geometry and attributes — to analyse, restyle, or edit. Heavier. |
| WMTS | Pre-rendered tiles on a fixed grid | Base 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.
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.
# 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.
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);
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.
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.
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.
GetCapabilities document. Find and record: the layer's name, its bounding box, its supported CRS list, and the formats offered.GetMap URL by hand and open it directly.outputFormat=application/json and load the result with L.geoJSON.GetFeatureInfo so clicking a WMS layer returns attributes, and compare the user experience with the WFS approach.GetCapabilities document, and who reads it?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.
Week eight
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:
| Route | Does |
|---|---|
GET /api/reports | List, filtered by bbox and query parameters |
GET /api/reports/<id> | One report, or 404 |
POST /api/reports | Create; returns 201 and the new id |
PATCH /api/reports/<id> | Modify some fields |
DELETE /api/reports/<id> | Remove; usually a soft delete in practice |
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.
JavaScriptconst 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.");
}
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.
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.
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.
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.
reports table with geometry, category, description, reporter, status and timestamp.curl. Try to delete a report you did not create. Document what you tried and what happened.PATCH whose version does not match the current row, and handle the conflict in the interface.cur.execute(f"... WHERE id = {uid}") safely, and explain why yours cannot be injected.GET never modify data?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.
Week nine
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.
| Symptom | Likely cause | Fix |
|---|---|---|
| Slow first load, one huge request | Whole dataset as GeoJSON | Bbox filtering, or vector tiles |
| Fine at low zoom, freezes at high | Too many DOM elements | Canvas rendering, clustering, vector tiles |
| Hundreds of small requests | Bound to move not moveend; no caching | Debounce; cache headers |
| Fast locally, slow deployed | No compression; distant server | Enable gzip/brotli; use a CDN |
| Boundaries with millions of vertices | Survey-grade data on a web map | Zoom-dependent ST_Simplify |
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.
-- 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;
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.
ST_Simplify and coordinate precision reduction. Measure again.preferCanvas: true. Measure interaction smoothness at high zoom with many features.ST_AsMVT. Measure again.tippecanoe, host it as a static file, and load it in MapLibre. Compare operational complexity with the GeoServer route.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.
Week ten · CAT 2 this week
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.
OSM is a free, editable map of the world built by volunteers. Its data model has three primitives and one organising idea:
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.
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.
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.
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.
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.
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.
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.
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.
Week eleven
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.
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:
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.
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.
Appendix A
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.
for loop, once with reduce.name and beds, return an array of just the names of those with more than 100 beds. Use filter and map.const x = {a: 1}; x.a = 2; does, and why it is not an error."Nairobi has 34 facilities" from variables county and n.console.log("5" == 5, "5" === 5);async function that fetches JSON from a URL, throws on a non-200 status, and returns the parsed object.let d; fetch(u).then(r => r.json()).then(j => d = j); console.log(d.length);facilities grouped by level.facilities to counties on county_id and return county name with facility count. Include counties with zero facilities.WHERE and HAVING, with an example of each.INNER JOIN and a LEFT JOIN, and say which task 3 needs.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.
this in JavaScript. It means different things in different call contexts. Arrow functions inherit this from where they were written, which is nearly always what you want — prefer them in callbacks.== versus ===. The double equals coerces types, so "" == 0 is true and null == undefined is true. Use === always, without exception.undefined right after a fetch, nothing is broken — the fetch has not finished. Week 2 covers this properly.JOIN or one window function away from the right answer.NULL is not a value. NULL = NULL is not true, it is NULL. Use IS NULL. This bites in outer joins, where the absent side is all nulls.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.
Appendix B
| EPSG | Name | Use for |
|---|---|---|
| 4326 | WGS 84, lon/lat degrees | Storage and interchange. GeoJSON requires it. Never measure in it. |
| 3857 | Web Mercator | Tiles and web display. Never measure in it either. |
| 32736 | UTM zone 36S | Measurement in Kenya west of 36°E — Kisumu, Kakamega, Eldoret |
| 32737 | UTM zone 37S | Measurement in Kenya east of 36°E — Nairobi, Thika, Mombasa |
| 21037 | Arc 1960 / UTM 37S | Older Kenyan survey data. Check the datum before mixing with WGS 84 sources. |
| 200 / 201 / 204 | OK · Created · No content (a successful delete) |
| 304 | Not modified — your cached copy is still good |
| 400 / 404 | Your request was malformed · Nothing at that address |
| 401 / 403 | Not authenticated · Authenticated but not permitted |
| 500 / 502 / 504 | Server error · Bad gateway · Gateway timeout |
| Symptom | Usual cause |
|---|---|
| Blank grey box | Map div has no height; or script ran before the div existed |
| Tiles stacked at top-left | Leaflet CSS not loaded |
| Data plots in the Indian Ocean | Coordinate order reversed — lat/lon instead of lon/lat |
fetch blocked, file:// in the URL | You opened the HTML by double-clicking. Serve over HTTP. |
| CORS error, status 200 | Response arrived; server did not permit your origin to read it |
| Area or distance is a small decimal | Computed in degrees. Transform or cast to geography. |
| Query fast in dev, slow in production | Missing GiST index, or a function wrapping the indexed column |
| WMS draws an opaque white box | transparent: true omitted |
| Map shows stale data after a database update | Tile cache not invalidated |
undefined immediately after a fetch | The fetch has not finished. Work inside the handler. |
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)
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);
Appendix C
| Dataset | Used in | Notes |
|---|---|---|
| Kenya county boundaries | 4–7 | All 47 counties. Supplied simplified and full so you can measure the difference in week 9. |
| Health facility register | 3–8 | Name, level, ownership, beds, coordinates. Real data, with real problems — duplicates, missing coordinates, points in the sea. Cleaning it is part of the exercise. |
| County population | 4 | From the 2019 census. The denominator for every rate you compute. |
| Classified roads | 5, 9 | Large enough that naive GeoJSON delivery fails, which is the point in week 9. |
| OSM extract, Kenya | 10 | From Geofabrik. Used for the quality assessment and the ODbL discussion. |
| Campus survey data | 2, 8 | JKUAT buildings and paths. Small, local, and yours to extend. |
| OpenStreetMap tiles | Development only — read the tile usage policy before you deploy anything |
| Carto / Stadia / MapTiler | Free tiers adequate for coursework; check the attribution requirements |
| Geofabrik | OSM extracts by country and region, updated daily |
| Overpass API | Querying OSM directly for specific features |
| Nominatim | Geocoding. Rate-limited — respect it, and never use it for bulk geocoding |
| Netlify / GitHub Pages / Cloudflare Pages | Free 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.