EGE 2531 Web Mapping · Week 2 laboratory

HTML, CSS and JavaScript, by building a map page

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.

3 h lab + take-home VS Code · Live Server · a browser Not assessed Companion to Week 2 of the reader

Plan and outcomes

By the end of this practical you should be able to
  • Explain what an HTML element, tag and attribute are, and write a page skeleton from memory
  • Use CSS selectors (element, class, id) and build a full-height sidebar-plus-map layout with flexbox
  • Write JavaScript variables, objects, arrays, functions and loops, and say where JavaScript differs from Python
  • Change a page from JavaScript (the DOM) and react to user input (events)
  • Load a file with fetch, and explain why the data is not there on the next line
StepYou buildConceptsTime
0Project folder, Live Server runningLocal server, DevTools15 min
1Page skeletonElements, tags, attributes, nesting25 min
2Styled full-height layoutSelectors, box model, flexbox35 min
3Facility data in JavaScriptVariables, objects, arrays, functions35 min
4Working filtersDOM, events30 min
5All 60 facilities from GeoJSONfetch, promises, async/await30 min
6Dots on a "map"lon/lat to pixels, coordinate ordertake-home
00

15 minutes

Setup

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:

Folder
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:

TabWhat it showsWhen you use it
ElementsThe page as the browser understood it"Why is my element not where I expect?"
ConsoleMessages from your JavaScript, and its errors in redEvery time something does not work
NetworkEvery 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.

Check

The address starts with http://127.0.0.1:5500, and saving index.html reloads the page by itself.

01

25 minutes

HTML: the structure

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.

Anatomy of an element

<p class="subtitle">EGE 2531 · Week 2 practical</p>

PartHereMeaning
Opening tag<p class="subtitle">"A paragraph starts here"
Attributeclass="subtitle"Extra information about the element, written name="value" inside the opening tag
ContentEGE 2531 · Week 2 practicalWhat the element contains: text, or other elements
Closing tag</p>"The paragraph ends here". Same name, with a slash
Elementthe whole lineOpening 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

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>
ElementJob
<!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
Check

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.

Try it

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.

02

35 minutes

CSS: selectors and layout

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.

Anatomy of a rule

CSS
.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.

SelectorWrittenMatchesIn our page
Elementh1Every <h1>Top-level headings
Class.sidebarEvery element with class="sidebar"The <aside>
Id#mapThe one element with id="map"The map box
Descendant.site-header h1<h1> inside .site-headerThe title only
Grouphtml, bodyBothThe 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.

The box model

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.

Flexbox

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>

HTML
<link rel="stylesheet" href="style.css">

2.2Type style.css

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.

Check

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.

Try it

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.

03

35 minutes

JavaScript: the language

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.

IdeaPythonJavaScript
BlocksIndentation{ braces }. Indentation is for humans
End of statementEnd of line; (write it)
Never changesx = 5const x = 5;
Changesx = 5let 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
Loopfor f in fs:for (const f of fs) { … }
Functiondef describe(f):function describe(f) { … }
Inline functionlambda f: f.level >= 4f => f.level >= 4
f-stringf"{name}"`${name}` (backticks)
True/False/NoneTrue False Nonetrue false null (and undefined)
and/or/notand or not&& || !
Equality===== always
Printprint(x)console.log(x)
for…of, not for…in

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>

HTML
<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

app.js
// 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 ==
Check

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:

Console
facilities[2].operational
facilities.map(f => f.name)
facilities.filter(f => !f.operational).length
describe(facilities[4])
Try it

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.

04

30 minutes

The DOM and events

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 toWriteNote
Find an elementdocument.querySelector("#status")Takes a CSS selector, as in Step 2
Change its textel.textContent = "Hi"Shown exactly as written
Make an elementdocument.createElement("li")Only in memory until attached
Attach itlistEl.appendChild(li)Now it is visible
Add a classli.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

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>
  <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

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

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.

Check

"Showing 5 of 5 facilities", with Kikuyu Health Centre struck through. Level 4 and above gives 2. "Operational only" at level 2 gives 4.

Try it

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.

05

30 minutes

fetch, promises and async/await

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.

Why it does not wait

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):

Console
const 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".

The bug everyone writes

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.

Two ways to write it correctly

JavaScript
// 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.

A Feature, as JavaScript sees it

GeoJSON
{
  "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

app.js
// 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();
Check

"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.

Try it

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.

06

Extension and take-home

A map with no mapping library

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:

x = lon − westeast − west     y = north − latnorth − south

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

JavaScript
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()

JavaScript
// 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

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.

Check

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.

Try it: coordinate order

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.

Take-home questions, answered in writing
  1. Make the window tall and narrow, then short and wide. What happens to the pattern of dots? Why does a real map not do this?
  2. There are no roads, county boundary or towns behind the dots. What would you need in order to draw them, and where would that data come from?
  3. Your page cannot zoom in on Juja. List what would have to change in drawDots to support zooming and panning.
  4. The formula treats a degree of longitude as the same distance as a degree of latitude. That is nearly true near the equator. Would it be true in Norway?

Your answers are the list of problems a web map library solves: tiles, projections, zoom and pan. That is Week 3.

When it does not work

Open the Console first, every time.

SymptomLikely causeFix
Page does not update on saveLive Server not running, or a different copy of the fileAddress should start http://127.0.0.1:5500; check VS Code has the right folder open
Styles do nothing<link> missing or misspeltNetwork tab: is style.css there, status 200?
One rule is ignoredMissing ; or } above it, or ./# mixed upElements → Styles: struck-through rules lost to a more specific one
Map area is a thin stripNothing gives the page a heighthtml, body { height: 100%; } and flex: 1 (Step 2)
Cannot read properties of nullquerySelector found nothingCompare selector with HTML; <script> at the end of <body>
… of undefined after a fetchData used before it arrivedWork inside the async function, after await (Step 5)
… is not definedMisspelt or wrong-case nameClick the file:line link in the Console
Red HTTP 404 messageWrong path or file namePaths are relative to index.html, and case-sensitive on a server
CORS errorPage opened as file://Use Live Server (Step 0)
Dots missing, status says 60Coordinates swappedGeoJSON is [lon, lat] (Step 6)
Self-test
  1. In <a href="map.html" class="nav">Map</a>, name the element, both attributes and the content. (Step 1)
  2. Write a selector for every <li> inside the element with id facility-list, and one for every element with class closed. (Step 2)
  3. Why does the sidebar stay 320px while the map grows with the window? Name the two properties responsible. (Step 2)
  4. levelSelect.value is "4". What is levelSelect.value + 1, and how do you get 5? (Step 4)
  5. A student writes 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.