EGE 2531 Web Mapping · Week 2 laboratory
One small page, Kiambu Health Facilities, built in six steps. It loads 60 facilities from GeoJSON, lists them, filters them and plots them. Next week Leaflet replaces the dots with a real map, and nothing else changes.
fetch, and explain why the data is not there on the next line| Step | You build | Concepts | Time |
|---|---|---|---|
| 0 | Project folder, Live Server running | Local server, DevTools | 15 min |
| 1 | Page skeleton | Elements, tags, attributes, nesting | 25 min |
| 2 | Styled full-height layout | Selectors, box model, flexbox | 35 min |
| 3 | Facility data in JavaScript | Variables, objects, arrays, functions | 35 min |
| 4 | Working filters | DOM, events | 30 min |
| 5 | All 60 facilities from GeoJSON | fetch, promises, async/await | 30 min |
| 6 | Dots on a "map" | lon/lat to pixels, coordinate order | take-home |
15 minutes
By the end of this step, VS Code shows your project folder and Live Server serves it at http://127.0.0.1:5500.
0.1Make the project folder
Create a folder called week2-practical, and inside it a folder called data. Put facilities.geojson in data/. By the end of Step 3 you will have:
week2-practical/
├── data/
│ └── facilities.geojson
├── index.html ← Step 1
├── style.css ← Step 2
└── app.js ← Step 3
0.2Open the folder in VS Code
Use File → Open Folder and choose week2-practical itself, not a file inside it and not the folder above it. Live Server serves whatever folder is open.
0.3Start Live Server
Create index.html, type hello in it and save. Right-click the file and choose Open with Live Server, or click Go Live in the status bar. The browser opens at http://127.0.0.1:5500/index.html. Change the word and save: the page reloads by itself.
0.4Open the developer tools
Press F12 (or Ctrl+Shift+I). You will use three tabs all semester:
| Tab | What it shows | When you use it |
|---|---|---|
| Elements | The page as the browser understood it | "Why is my element not where I expect?" |
| Console | Messages from your JavaScript, and its errors in red | Every time something does not work |
| Network | Every file the page asked for, with its status code | "Did my data file actually load?" |
Why a server, and not a double-click? A double-clicked file opens at an address starting file://. For security, browsers refuse to let JavaScript fetch files from a file:// page, so Step 5 would fail with a confusing CORS error. Live Server gives your folder a real http:// address, the way the departmental VPS will later in the unit.
The address starts with http://127.0.0.1:5500, and saving index.html reloads the page by itself.
25 minutes
HTML says what each part of the page is: a heading, a list, a sidebar, a box for the map. It says nothing about how they look. That is Step 2.
<p class="subtitle">EGE 2531 · Week 2 practical</p>
| Part | Here | Meaning |
|---|---|---|
| Opening tag | <p class="subtitle"> | "A paragraph starts here" |
| Attribute | class="subtitle" | Extra information about the element, written name="value" inside the opening tag |
| Content | EGE 2531 · Week 2 practical | What the element contains: text, or other elements |
| Closing tag | </p> | "The paragraph ends here". Same name, with a slash |
| Element | the whole line | Opening tag + content + closing tag |
A few elements have no content and no closing tag, for example <meta>, <link> and <input>. They are called void elements.
Elements nest. An element can contain other elements, like folders inside folders. <ul> contains <li> items, and <aside> contains the heading, the paragraph and the list. The result is a tree, and in Step 4 JavaScript walks that tree to find elements and change them. Indent your code so you can see the tree. The browser ignores indentation; you should not.
id: a unique name. Only one element on the page may have id="map". Leaflet finds its map container by id next week.
class: a group. Many elements can share class="sidebar", and one element can have several classes.
1.1Type the page into index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kiambu Health Facilities</title>
</head>
<body>
<header class="site-header">
<h1>Kiambu Health Facilities</h1>
<p class="subtitle">EGE 2531 · Week 2 practical</p>
</header>
<main class="layout">
<aside class="sidebar">
<h2>Facilities</h2>
<p id="status" class="status">Three facilities, typed by hand for now.</p>
<ul id="facility-list" class="facility-list">
<li>Juja Level 5 Hospital — level 5, 301 beds</li>
<li>Ruiru Health Centre — level 3, 32 beds</li>
<li>Kikuyu Health Centre — level 3, 45 beds</li>
</ul>
<p>Data: <a href="https://mbanibenson.github.io/jkuat-geospatial-teaching/">EGE 2531 data pack</a> (synthetic).</p>
</aside>
<div id="map" class="map">
<p>The map will go here.</p>
</div>
</main>
</body>
</html>
| Element | Job |
|---|---|
<!DOCTYPE html> | "This is modern HTML." Always the first line |
<head> | Information about the page: character set, title, stylesheets. Not shown |
<meta name="viewport"> | Makes the page fit a phone screen instead of shrinking a desktop layout |
<body> | Everything the user sees |
<header> <main> <aside> | Semantic containers: they tell browsers and screen readers what each region is for |
<div> | A container with no meaning of its own. The map box, here |
<a href="…"> | A link. href is where it goes |
The title, the subtitle, three facilities in a bulleted list, and "The map will go here." It looks plain and ugly. That is correct: there is no CSS yet.
Open DevTools → Elements and expand the tree: it matches your indentation. Now delete the </ul> tag, save, and look again. The browser quietly guessed where the list ends and showed no error. HTML is forgiving, so mistakes hide. Put the tag back.
35 minutes
CSS says how elements look and where they sit. The goal is the layout of almost every web map: a header on top, a fixed-width sidebar on the left, and a map that fills the rest of the window.
.site-header h1 {
margin: 0;
font-size: 1.4rem;
}
The selector (.site-header h1) says which elements the rule applies to. Each declaration inside the braces is a property: value; pair. The semicolons matter. A missing one silently breaks the next line.
| Selector | Written | Matches | In our page |
|---|---|---|---|
| Element | h1 | Every <h1> | Top-level headings |
| Class | .sidebar | Every element with class="sidebar" | The <aside> |
| Id | #map | The one element with id="map" | The map box |
| Descendant | .site-header h1 | <h1> inside .site-header | The title only |
| Group | html, body | Both | The page root |
The dot means class and the hash means id, here and again in JavaScript in Step 4. When two rules disagree, the more specific selector wins: an id beats a class, and a class beats an element. If they are equally specific, the later rule wins.
Every element is a rectangle made of four layers, from the inside out: content, padding (space inside the border), border, and margin (space outside it, pushing neighbours away). By default width: 320px sets only the content width, and padding and border are added on top. * { box-sizing: border-box; } makes 320px mean the whole box. Every sensible stylesheet starts with it.
display: flex lays a container's children out in a row, or in a column with flex-direction: column. A child with flex: 1 takes all the space left over. We use it twice. body is a column, and main gets flex: 1, so it takes all the height below the header. main is a row, and #map gets flex: 1, so it takes all the width beside the 320px sidebar.
2.1Link the stylesheet: inside <head>, under <title>
<link rel="stylesheet" href="style.css">
2.2Type style.css
/* Kiambu Health Facilities — EGE 2531 week 2 */
/* Colours defined once, used everywhere with var(--name) */
:root {
--teal: #0E6E78;
--ink: #1d2327;
--paper: #f7f7f5;
--line: #d9dcd8;
}
/* Width and height include padding and border — the sane box model */
* {
box-sizing: border-box;
}
/* Let the page fill the whole window, with no default gap round the edge */
html,
body {
height: 100%;
margin: 0;
}
/* body stacks the header above the main area: a column flexbox */
body {
display: flex;
flex-direction: column;
font-family: system-ui, sans-serif;
color: var(--ink);
background: var(--paper);
}
.site-header {
background: var(--teal);
color: white;
padding: 12px 20px;
}
.site-header h1 {
margin: 0;
font-size: 1.4rem;
}
.subtitle {
margin: 4px 0 0;
font-size: 0.9rem;
opacity: 0.85;
}
/* main takes all the height left over, and puts sidebar and map side by side */
.layout {
flex: 1;
display: flex;
min-height: 0;
}
.sidebar {
width: 320px;
padding: 16px;
overflow-y: auto;
background: white;
border-right: 1px solid var(--line);
}
/* the map takes all the width left over */
#map {
flex: 1;
position: relative;
background: #e3ece6;
}
/* On a phone: sidebar on top, map underneath */
@media (max-width: 700px) {
.layout {
flex-direction: column;
}
.sidebar {
width: auto;
max-height: 40vh;
border-right: none;
border-bottom: 1px solid var(--line);
}
}
The @media block is a media query: its rules apply only when the window is 700px wide or less. That one block makes the page usable on a phone.
A teal header, a white sidebar on the left, and a pale green map area filling the rest of the window down to the bottom edge, with no page scrollbar. Make the window narrow, or press Ctrl+Shift+M in DevTools: the sidebar moves above the map.
Delete height: 100%; from the html, body rule. The map area no longer reaches the bottom of the window: it stops where the sidebar's text stops. With no height on the page, flex: 1 has no space to share out. A box is only as tall as its content unless you give it a height. A Leaflet container has no content of its own, so this is the most common reason a map appears as a blank strip. Put the line back.
In Elements, click the <aside> and find the box diagram in the Computed pane. Find the 16px padding and the 1px right border.
35 minutes
JavaScript is the only language a browser runs, so every interactive web map is written in it. Most of it is ideas you know from Python with different punctuation. In this step you put facility data into JavaScript and work with it in the console. The page does not change yet.
| Idea | Python | JavaScript |
|---|---|---|
| Blocks | Indentation | { braces }. Indentation is for humans |
| End of statement | End of line | ; (write it) |
| Never changes | x = 5 | const x = 5; |
| Changes | x = 5 | let x = 5; Never the old var |
| Comment | # note | // note |
| Dictionary | {"level": 3} | Object { level: 3 }, read as f.level |
| List | [1, 2], len(a) | Array [1, 2], a.length |
| Loop | for f in fs: | for (const f of fs) { … } |
| Function | def describe(f): | function describe(f) { … } |
| Inline function | lambda f: f.level >= 4 | f => f.level >= 4 |
| f-string | f"{name}" | `${name}` (backticks) |
| True/False/None | True False None | true false null (and undefined) |
| and/or/not | and or not | && || ! |
| Equality | == | === always |
print(x) | console.log(x) |
for (const f of facilities) gives you the items, which is what you want. for…in gives you the index numbers as strings, which is almost never what you want.
3.1Connect a script: just before </body>
<script src="app.js"></script>
It goes at the bottom so the elements above it already exist when it runs. Step 4 depends on that.
3.2Type app.js. The five facilities are real rows from the data file
// Step 3 — JavaScript basics.
// Nothing changes on the page yet. Open the console (F12 → Console) to see the output.
// An array of objects. Each object is one facility, like a Python dict.
// Coordinates are kept as lon and lat so there is no doubt which is which.
const facilities = [
{ name: "Juja Level 5 Hospital", level: 5, beds: 301, operational: true, lon: 36.673763, lat: -0.804421 },
{ name: "Ruiru Health Centre", level: 3, beds: 32, operational: true, lon: 36.71698, lat: -0.893475 },
{ name: "Kikuyu Health Centre", level: 3, beds: 45, operational: false, lon: 36.846252, lat: -0.985171 },
{ name: "Juja Dispensary", level: 2, beds: 2, operational: true, lon: 36.671381, lat: -1.186882 },
{ name: "Githunguri Sub-County Hospital", level: 4, beds: 135, operational: true, lon: 36.942477, lat: -1.084828 },
];
console.log("Number of facilities:", facilities.length);
console.log("The first one:", facilities[0]);
console.log("Its name:", facilities[0].name);
// A function. Backticks make a template literal: ${...} is Python's f-string {...}.
function describe(facility) {
return `${facility.name} is level ${facility.level} with ${facility.beds} beds`;
}
// Loop over the array. "for...of" is Python's "for ... in".
for (const f of facilities) {
console.log(describe(f));
}
// Keep only some items. The arrow function f => ... is Python's lambda f: ...
const hospitals = facilities.filter(f => f.level >= 4);
console.log("Level 4 and above:", hospitals.length);
// Add up a column. let, not const, because total changes.
let totalBeds = 0;
for (const f of facilities) {
totalBeds = totalBeds + f.beds;
}
console.log("Total beds:", totalBeds);
// === compares value AND type. == converts types first, and surprises you.
console.log("3 === '3' is", 3 === "3"); // false
console.log("3 == '3' is", 3 == "3"); // true — this is why we never use ==
The Console shows 5 facilities, five "… is level … with … beds" lines, Level 4 and above: 2 and Total beds: 515, with no red text.
The console is also a calculator. Type these into it, pressing Enter after each. Your script's variables are available there:
Consolefacilities[2].operational
facilities.map(f => f.name)
facilities.filter(f => !f.operational).length
describe(facilities[4])
Rename const facilities to const Facilities and save. The Console says ReferenceError: facilities is not defined, with a file and line number. Names are case-sensitive, and the console tells you exactly where it gave up. Click the app.js:… link to jump to the line. Then fix it.
3.3Exercise
Write bedsPerLevel(list, level), which returns the total beds at one level. In the Console, bedsPerLevel(facilities, 3) should give 77.
30 minutes
The browser turns your HTML into a tree of objects called the DOM (Document Object Model), and JavaScript can change that tree at any time. In this step JavaScript writes the facility list itself, and two filters redraw it. This is how every interactive map works: the data lives in JavaScript, and the page is redrawn from it.
| You want to | Write | Note |
|---|---|---|
| Find an element | document.querySelector("#status") | Takes a CSS selector, as in Step 2 |
| Change its text | el.textContent = "Hi" | Shown exactly as written |
| Make an element | document.createElement("li") | Only in memory until attached |
| Attach it | listEl.appendChild(li) | Now it is visible |
| Add a class | li.classList.add("closed") | The CSS for .closed applies |
Events are things that happen: a click, a key press, a change to a drop-down. el.addEventListener("change", render) means "when this changes, call render". It is render, not render(): you hand over the function for the browser to call later. Writing render() would call it once, immediately.
4.1In index.html: replace the inside of <aside>, empty the #map div
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kiambu Health Facilities</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="site-header">
<h1>Kiambu Health Facilities</h1>
<p class="subtitle">EGE 2531 · Week 2 practical</p>
</header>
<main class="layout">
<aside class="sidebar">
<h2>Filter</h2>
<label for="level-select">Minimum KEPH level</label>
<select id="level-select">
<option value="2">Level 2 and above</option>
<option value="3">Level 3 and above</option>
<option value="4">Level 4 and above</option>
<option value="5">Level 5 only</option>
</select>
<label class="check">
<input type="checkbox" id="operational-only">
Operational only
</label>
<p id="status" class="status">Loading…</p>
<ul id="facility-list" class="facility-list"></ul>
<p class="credit">Data: <a href="https://mbanibenson.github.io/jkuat-geospatial-teaching/">EGE 2531 data pack</a> (synthetic).</p>
</aside>
<div id="map" class="map"></div>
</main>
<script src="app.js"></script>
</body>
</html>
The list is now empty, because JavaScript fills it. for="level-select" ties the label to the drop-down: clicking the label focuses the drop-down, and screen readers announce the label.
4.2Append to style.css
/* ---- Step 4: form controls and the list ---- */
.sidebar label {
display: block;
margin: 12px 0 4px;
font-weight: 600;
}
.sidebar label.check {
font-weight: normal;
}
.sidebar select {
width: 100%;
padding: 6px;
font: inherit;
}
.status {
margin: 16px 0 8px;
font-weight: 600;
color: var(--teal);
}
.status.error {
color: #b3261e;
}
.facility-list {
padding-left: 18px;
margin: 0;
}
.facility-list li {
margin-bottom: 4px;
}
/* A class added by JavaScript: facilities that are not operational */
.facility-list li.closed {
color: #8a8f8c;
text-decoration: line-through;
}
.credit {
margin-top: 24px;
font-size: 0.8rem;
color: #666;
}
4.3Replace app.js
// Step 4 — the DOM and events.
// The facility list is now written by JavaScript, and the filters redraw it.
const facilities = [
{ name: "Juja Level 5 Hospital", level: 5, beds: 301, operational: true, lon: 36.673763, lat: -0.804421 },
{ name: "Ruiru Health Centre", level: 3, beds: 32, operational: true, lon: 36.71698, lat: -0.893475 },
{ name: "Kikuyu Health Centre", level: 3, beds: 45, operational: false, lon: 36.846252, lat: -0.985171 },
{ name: "Juja Dispensary", level: 2, beds: 2, operational: true, lon: 36.671381, lat: -1.186882 },
{ name: "Githunguri Sub-County Hospital", level: 4, beds: 135, operational: true, lon: 36.942477, lat: -1.084828 },
];
// Find the elements once. querySelector takes a CSS selector — the same ones as style.css.
const levelSelect = document.querySelector("#level-select");
const operationalBox = document.querySelector("#operational-only");
const statusEl = document.querySelector("#status");
const listEl = document.querySelector("#facility-list");
// Does this facility pass the current filters?
function matchesFilters(facility) {
const minLevel = Number(levelSelect.value); // .value is always a string
if (facility.level < minLevel) return false;
if (operationalBox.checked && !facility.operational) return false;
return true;
}
// Redraw the list from the data. Called at the start and after every change.
function render() {
const shown = facilities.filter(matchesFilters);
statusEl.textContent = `Showing ${shown.length} of ${facilities.length} facilities`;
listEl.innerHTML = ""; // empty the list
for (const f of shown) {
const li = document.createElement("li"); // make a new <li>
li.textContent = `${f.name} — level ${f.level}, ${f.beds} beds`;
if (!f.operational) li.classList.add("closed");
listEl.appendChild(li); // put it in the <ul>
}
}
// Events: "when this control changes, run render".
levelSelect.addEventListener("change", render);
operationalBox.addEventListener("change", render);
render();
Follow the flow. On load, render() runs once and draws 5 facilities. When the user picks "Level 4 and above", the browser fires change and calls render again. render reads the filters, filters the array, empties the list and rebuilds it. The data never changes. Only the view of it does.
"Showing 5 of 5 facilities", with Kikuyu Health Centre struck through. Level 4 and above gives 2. "Operational only" at level 2 gives 4.
Remove Number(…) so the line reads const minLevel = levelSelect.value;. It still works, by luck: < quietly converts "4" to 4. === would not, and with +, "4" + 1 gives "41". Values from form controls are always strings, so convert them yourself.
Move the <script> tag into <head>. The Console says Cannot read properties of null: the script ran before the elements existed, and querySelector found nothing. Move it back.
4.4Exercise
Add a Reset button that sets the drop-down to level 2, unticks the box and redraws. You need <button id="reset">, a "click" event, levelSelect.value = "2" and operationalBox.checked = false.
30 minutes
Five hand-typed facilities is not a map. In this step the page loads all 60 from data/facilities.geojson over HTTP, the same way it will later load from PostGIS and GeoServer. There is one idea to understand, and it causes more confusion than anything else in this unit: JavaScript never waits.
Python runs a line, waits for it to finish, and moves on. A browser cannot: while it waited two seconds for a file over a slow network, nothing on the page could be clicked or scrolled. So fetch returns immediately with a promise, an object that stands for "the result, once it arrives". Your code tells the promise what to do when it arrives, and carries on.
Paste this into the Console (the one time pasting is fine):
Consoleconst wait = ms => new Promise(resolve => setTimeout(resolve, ms));
console.log("A");
wait(2000).then(() => console.log("C"));
console.log("B");
It prints A, B, and C two seconds later. .then(…) means "when this promise is fulfilled, run this function".
You will write this at least once this semester:
// WRONG — data is still undefined when the last line runs
let data;
fetch("data/facilities.geojson").then(r => r.json()).then(d => { data = d; });
console.log(data.features.length); // TypeError: Cannot read properties of undefined
Nothing is broken. The fetch has not finished when the last line runs. The fix is always the same: do the work inside the handler, the code that runs once the data has arrived.
// 1. With .then() — a chain of "and when that's done..."
fetch("data/facilities.geojson")
.then(response => response.json())
.then(geojson => console.log(geojson.features.length))
.catch(err => console.error("failed:", err));
// 2. With async/await — reads top to bottom, like Python
async function load() {
const response = await fetch("data/facilities.geojson");
const geojson = await response.json();
console.log(geojson.features.length);
}
load();
await means "pause this function until the promise settles". The rest of the page keeps running. await works only inside a function marked async. We use async/await from here on because it reads like Python. Examples online use both styles, so learn to recognise both.
{
"type": "Feature",
"properties": { "name": "Ngewa Health Centre", "county": "Kiambu", "level": 3, "beds": 47, "operational": true },
"geometry": { "type": "Point", "coordinates": [36.62885, -1.177803] }
}
The attributes are in feature.properties. The location is feature.geometry.coordinates, written [longitude, latitude]: x first, then y. The new code turns each Feature into the flat object render() already understands, so nothing else changes.
5.1Replace app.js. Compare it with Step 4: only the top and the bottom change
// Step 5 — fetch, promises and async/await.
// The five hand-typed facilities are gone: all 60 now come from the GeoJSON file.
const DATA_URL = "data/facilities.geojson";
let facilities = []; // empty until the file has arrived
const levelSelect = document.querySelector("#level-select");
const operationalBox = document.querySelector("#operational-only");
const statusEl = document.querySelector("#status");
const listEl = document.querySelector("#facility-list");
function matchesFilters(facility) {
const minLevel = Number(levelSelect.value);
if (facility.level < minLevel) return false;
if (operationalBox.checked && !facility.operational) return false;
return true;
}
function render() {
const shown = facilities.filter(matchesFilters);
statusEl.textContent = `Showing ${shown.length} of ${facilities.length} facilities`;
listEl.innerHTML = "";
for (const f of shown) {
const li = document.createElement("li");
li.textContent = `${f.name} — level ${f.level}, ${f.beds} beds`;
if (!f.operational) li.classList.add("closed");
listEl.appendChild(li);
}
}
// Turn one GeoJSON Feature into the flat object the rest of the code expects.
// GeoJSON coordinates are [longitude, latitude] — x first, then y.
function toFacility(feature) {
const p = feature.properties;
return {
name: p.name,
level: p.level,
beds: p.beds,
operational: p.operational,
lon: feature.geometry.coordinates[0],
lat: feature.geometry.coordinates[1],
};
}
// async lets us write await: "pause THIS function until the promise settles".
// The rest of the page keeps running while we wait.
async function loadFacilities() {
try {
const response = await fetch(DATA_URL);
if (!response.ok) {
// fetch does NOT fail on a 404 — we have to check ourselves
throw new Error(`HTTP ${response.status}`);
}
const geojson = await response.json();
facilities = geojson.features.map(toFacility);
render(); // only now is there anything to draw
} catch (err) {
statusEl.textContent = `Could not load the facilities (${err.message}). Is Live Server running, and is the file in data/?`;
statusEl.classList.add("error");
console.error(err);
}
}
levelSelect.addEventListener("change", render);
operationalBox.addEventListener("change", render);
loadFacilities();
geojson.features.map(toFacility) builds a new array by calling toFacility on every Feature. It is Python's [to_facility(f) for f in features].render() is called inside loadFacilities, after the data has arrived. That is the fix for the bug above.try { … } catch (err) { … } is Python's try/except. The user sees a message they can act on instead of an empty page.fetch does not treat 404 as an error. A "not found" reply is still a reply. Check response.ok yourself."Showing 60 of 60 facilities". Level 4 and above gives 19; level 5 only gives 8; "Operational only" at level 2 gives 54. In the Network tab, reload and find facilities.geojson: status 200, about 14 kB.
The 404. Rename the file to facility.geojson and reload. You get a red HTTP 404 message in the sidebar and a red request in Network. The page fails politely. Rename it back.
The bug. Add the three WRONG lines to the end of app.js. The Console shows Cannot read properties of undefined (reading 'features'), but the list still shows 60, because loadFacilities did its work in the right place. Delete them.
file://. Stop Live Server and double-click index.html. The fetch fails with a CORS or file:// error. That is why Step 0 set up a server.
Extension and take-home
In this step you plot each facility as a dot inside #map by converting longitude and latitude into screen positions. It is a crude map, deliberately so. What goes wrong with it is the best explanation of what Leaflet does for you next week.
Choose a box of the world to draw. Roughly, Kiambu is 36.5°E to 37.4°E and 1.35°S to 0.75°S. A facility's position across the box is how far its longitude is from the west edge, as a fraction of the width. Its position down the box is measured from the north edge, because screens count downwards and latitude counts upwards:
Both run from 0 to 1 inside the box. Multiplied by 100, they become CSS percentages for left and top.
6.1In app.js, under the other querySelector lines
const mapEl = document.querySelector("#map");
// The box of the world we are drawing: roughly Kiambu County, in degrees.
const BOUNDS = { west: 36.5, east: 37.4, south: -1.35, north: -0.75 };
6.2Add drawDots, and call drawDots(shown); as the last line of render()
// Place one dot per facility. left/top are percentages of the #map box.
function drawDots(list) {
mapEl.innerHTML = "";
for (const f of list) {
const x = (f.lon - BOUNDS.west) / (BOUNDS.east - BOUNDS.west); // 0 at west edge, 1 at east
const y = (BOUNDS.north - f.lat) / (BOUNDS.north - BOUNDS.south); // 0 at top, 1 at bottom: screens count downwards
const dot = document.createElement("div");
dot.className = `dot level-${f.level}`;
dot.style.left = `${x * 100}%`;
dot.style.top = `${y * 100}%`;
dot.title = `${f.name} (level ${f.level})`; // hover to see the name
if (!f.operational) dot.classList.add("closed");
mapEl.appendChild(dot);
}
}
6.3Append to style.css
/* ---- Step 6: the dots ---- */
.dot {
position: absolute; /* placed relative to #map, which is position: relative */
width: 10px;
height: 10px;
margin: -5px 0 0 -5px; /* centre the dot on its point, not its top-left corner */
border-radius: 50%;
background: var(--teal);
border: 1px solid white;
}
.dot.level-4 { width: 14px; height: 14px; margin: -7px 0 0 -7px; }
.dot.level-5 { width: 18px; height: 18px; margin: -9px 0 0 -9px; background: #084a51; }
.dot.closed {
background: white;
border: 2px solid #8a8f8c;
}
position: absolute on the dots and position: relative on #map (from Step 2) work together: left and top are measured from the corner of the map box, not the page. The complete file is step6/app.js in the checkpoints, and you can see it running.
60 dots: bigger, darker dots for levels 4 and 5, and hollow dots for the 6 facilities that are not operational. Hover a dot to see its name. Change the filters: the dots and the list update together, because render() draws both.
In toFacility, swap coordinates[0] and coordinates[1]. The status still says 60, but every dot has vanished. Longitude is now −1.2, about 4,000% of the box width off the left of the page. If you build Leaflet markers by hand from swapped coordinates, they land at 36.8°N, 1.0°W: in the Mediterranean, off south-east Spain. Either way, when points are missing or in the sea, check the coordinate order first. GeoJSON is [lon, lat]. Leaflet's L.marker([lat, lon]) is the other way round.
drawDots to support zooming and panning.Your answers are the list of problems a web map library solves: tiles, projections, zoom and pan. That is Week 3.
Open the Console first, every time.
| Symptom | Likely cause | Fix |
|---|---|---|
| Page does not update on save | Live Server not running, or a different copy of the file | Address should start http://127.0.0.1:5500; check VS Code has the right folder open |
| Styles do nothing | <link> missing or misspelt | Network tab: is style.css there, status 200? |
| One rule is ignored | Missing ; or } above it, or ./# mixed up | Elements → Styles: struck-through rules lost to a more specific one |
| Map area is a thin strip | Nothing gives the page a height | html, body { height: 100%; } and flex: 1 (Step 2) |
Cannot read properties of null | querySelector found nothing | Compare selector with HTML; <script> at the end of <body> |
… of undefined after a fetch | Data used before it arrived | Work inside the async function, after await (Step 5) |
… is not defined | Misspelt or wrong-case name | Click the file:line link in the Console |
| Red HTTP 404 message | Wrong path or file name | Paths are relative to index.html, and case-sensitive on a server |
| CORS error | Page opened as file:// | Use Live Server (Step 0) |
| Dots missing, status says 60 | Coordinates swapped | GeoJSON is [lon, lat] (Step 6) |
<a href="map.html" class="nav">Map</a>, name the element, both attributes and the content. (Step 1)<li> inside the element with id facility-list, and one for every element with class closed. (Step 2)levelSelect.value is "4". What is levelSelect.value + 1, and how do you get 5? (Step 4)const data = fetch("data/facilities.geojson"); then uses data.features. What is data actually holding, and what two changes fix it? (Step 5)Next. Finish Step 6 and its questions, then Practical 2 in the reader, GeoJSON by hand. In Week 3, Leaflet replaces drawDots with a real, zoomable map, and your HTML, CSS, filters and loadFacilities stay almost unchanged. Reference: MDN's "Getting started with the web" and "Using Fetch".
EGE 2531 Web Mapping · Week 2 practical · Jomo Kenyatta University of Agriculture and Technology · September 2026. Facility data is synthetic.