diff --git a/client_src/js/LayerDeviceMarkers.mjs b/client_src/js/LayerDeviceMarkers.mjs
index 894f7c8..11f2a1b 100644
--- a/client_src/js/LayerDeviceMarkers.mjs
+++ b/client_src/js/LayerDeviceMarkers.mjs
@@ -6,13 +6,16 @@ import 'leaflet.markercluster';
 // We're using the git repo for now until an update is released, and rollup doesn't like that apparently
 import CreateElement from '../../node_modules/dom-create-element-query-selector/src/index.js';
 import tabs from 'tabs';
+import Emitter from 'event-emitter-es6';
 
 import Config from './Config.mjs';
 import DeviceReadingDisplay from './DeviceReadingDisplay.mjs';
 import GetFromUrl from './Helpers/GetFromUrl.mjs';
 
-class LayerDeviceMarkers {
+class LayerDeviceMarkers extends Emitter {
 	constructor(in_map, in_device_data) {
+		super();
+		
 		this.map = in_map;
 		this.device_data = in_device_data;
 		
@@ -72,6 +75,8 @@ class LayerDeviceMarkers {
 		delete device_info.longitude;
 		
 		event.popup.setContent(this.render_device_info(device_info));
+		
+		this.emit("marker-popup-opened");
 	}
 	
 	render_device_info(device_info) {
diff --git a/client_src/js/MapManager.mjs b/client_src/js/MapManager.mjs
index 7533591..1b897a9 100644
--- a/client_src/js/MapManager.mjs
+++ b/client_src/js/MapManager.mjs
@@ -63,7 +63,7 @@ class MapManager {
 		// 	.then(() => console.info("[map] Time dimension initialised."));
 		
 		this.ui = new UI(Config, this);
-		this.ui.setup().then(() => console.log("[map] Settings initialised."));
+		this.ui.setup().then(() => console.log("[map] UI setup complete."));
 	}
 	
 	async setup_overlay() {
diff --git a/client_src/js/Tour.mjs b/client_src/js/Tour.mjs
new file mode 100644
index 0000000..9a944eb
--- /dev/null
+++ b/client_src/js/Tour.mjs
@@ -0,0 +1,155 @@
+"use strict";
+
+import Sheperd from 'shepherd.js';
+import '../../node_modules/shepherd.js/dist/css/shepherd-theme-default.css';
+
+class Tour {
+	constructor(in_map_manager) {
+		this.map_manager = in_map_manager;
+	}
+	
+	create_tour() {
+		this.tour = new Sheperd.Tour({
+			useModalOverlay: true,
+			defaultStepOptions: {
+				// Default settings for steps go here
+			}
+		});
+		
+		this.tour.addStep("welcome", {
+			text: "Welcome to the air quality web interface! Press next to get a short tour.",
+			buttons: this.get_buttons(false, true)
+		});
+		
+		this.tour.addStep("map", {
+			text: "Each device has a blue marker. The shape a marker is located in changes colour depending on the value of the measure it reported.",
+			attachTo: {
+				element: "#map",
+				on: "bottom"
+			},
+			buttons: this.get_buttons()
+		});
+		this.tour.addStep("guage", {
+			text: "This guage is a key to the colour of the shapes underneath the device markers.",
+			attachTo: {
+				element: document.querySelector("#canvas-guage"),
+				on: "left"
+			},
+			buttons: this.get_buttons()
+		});
+		
+		this.tour.addStep("reading-type", {
+			text: "Devices report multiple types of measurement. Change to another reading type now.",
+			attachTo: {
+				element: document.querySelector("select").parentNode,
+				on: "top"
+			},
+			advanceOn: { selector: "select", event: "change" },
+			buttons: this.get_buttons(true)
+		});
+		this.tour.addStep("reading-type-complete", {
+			text: "Hey, the map changed! Some devices only report certain types of measurement, and different measurements have different colour scales.",
+			attachTo: { element: "#map", on: "bottom" },
+			buttons: this.get_buttons()
+		});
+		this.tour.addStep("layer-showhide", {
+			text: "You can show and hide the device markers and the heatmap here.",
+			attachTo: {
+				element: ".leaflet-control-layers",
+				on: "left"
+			},
+			buttons: this.get_buttons()
+		});
+		
+		this.tour.addStep("map-controls", {
+			text: "You can control the zoom level and go fullscreen here. You can also zoom with your mouse wheel if you have one, and pan by clicking and dragging.",
+			attachTo: {
+				element: ".leaflet-top.leaflet-left",
+				on: "right"
+			},
+			buttons: this.get_buttons()
+		});
+		
+		this.tour.addStep("device-graph", {
+			text: "By clicking a blue marker, you can view additional information about that device. Not all device are actively reporting data. Try clicking one now.",
+			attachTo: {
+				element: "#map",
+				on: "top"
+			},
+			buttons: this.get_buttons(true)
+			
+		}).on("show", (() => {
+			this.map_manager.device_markers.once("marker-popup-opened", this.tour.next.bind(this.tour));
+		}).bind(this));
+		
+		this.tour.addStep("device-graph-a", {
+			text: "This is a device information popup. By default it displays recent data that the device has reported on the line graph, but you can control this with the blue buttons.",
+			attachTo: {
+				element: ".popup-device",
+				on: "right"
+			},
+			buttons: this.get_buttons()
+		});
+		this.tour.addStep("device-graph-b", {
+			text: "By clicking here, you can see the specification of the device, including its software, sensor models, exact location, more. Click this now.",
+			attachTo: {
+				element: ".tabs :first-child",
+				on: "left"
+			},
+			advanceOn: { selector: ".tabs :first-child a", event: "click" },
+			buttons: this.get_buttons(true)
+		});
+		
+		
+		this.tour.addStep("report-bug", {
+			text: "If you find a bug, you can report it by clicking this button.",
+			attachTo: {
+				element: "button[value='Report bug']",
+				on: "right"
+			},
+			buttons: this.get_buttons()
+		});
+		this.tour.addStep("changelog", {
+			text: "The changelog will be automatically shown every time this web interface updates. Click the version information here to review it again at other times.",
+			attachTo: {
+				element: "button[value~='built']",
+				on: "right"
+			},
+			buttons: this.get_buttons()
+		});
+		
+		
+		this.tour.addStep("complete", {
+			text: "Tour complete!\nIf you need any additional assistance, let us know :-)",
+			buttons: [{ text: "Done", action: this.tour.next }]
+		});
+	}
+	
+	run_once() {
+		if(!window.localStorage.has("completed_tour"))
+			this.run();
+		
+		window.localStorage.setItem("completed_tour", (new Date()).toISOString());
+	}
+	
+	run() {
+		if(typeof this.tour == "undefined")
+			this.create_tour();
+		this.tour.start();
+	}
+	
+	get_buttons(no_continue = false, no_prev = false) {
+		let next = { text: "Next", action: this.tour.next },
+			prev = { text: "Previous", action: this.tour.back },
+			exit = { text: "Exit", action: this.tour.cancel };
+		
+		let result = [];
+		if(!no_prev) result.push(prev);
+		if(!no_continue) result.push(next);
+		result.push(exit);
+		
+		return result;
+	}
+}
+
+export default Tour;
diff --git a/client_src/js/UI.mjs b/client_src/js/UI.mjs
index a2ed3d2..b0373ca 100644
--- a/client_src/js/UI.mjs
+++ b/client_src/js/UI.mjs
@@ -6,6 +6,16 @@ import NanoModal from 'nanomodal';
 import Config from './Config.mjs';
 import GetFromUrl from './Helpers/GetFromUrl.mjs';
 
+import Tour from './Tour.mjs';
+
+function show_nanomodal(html) {
+	return new Promise((resolve, _reject) => {
+		let modal = NanoModal(html);
+		modal.onHide(resolve);
+		modal.show();
+	});
+}
+
 async function show_changelog(only_if_changed) {
 	let current_version = `${Config.version}, built ${Config.build_date.toDateString()}`;
 	console.log(`[UI] Comparing current '${current_version}' to '${localStorage.getItem("last_seen_version")}'`);
@@ -15,9 +25,8 @@ async function show_changelog(only_if_changed) {
 	}
 	
 	console.log("[UI] Showing changelog");
-	NanoModal(
-		await GetFromUrl(`${Config.api_root}?action=changelog`)
-	).show();
+	await show_nanomodal(await GetFromUrl(`${Config.api_root}?action=changelog`));
+	
 	localStorage.setItem("last_seen_version", current_version);
 	return true;
 }
@@ -29,6 +38,8 @@ class UI {
 		
 		this.ui_panel = new SmartSettings("Settings");
 		// this.ui_panel.watch((event) => console.log(event));
+		
+		this.tour = new Tour(this.map_manager);
 	}
 	
 	async setup() {
@@ -64,7 +75,9 @@ class UI {
 		]);
 		this.ui_panel.setIndex("Reading Type", this.reading_types.findIndex((type) => type.short_descr == "PM25"));
 		
+		
 		await show_changelog(true);
+		await this.tour.run();
 	}
 }
 
diff --git a/package-lock.json b/package-lock.json
index fe973d1..d012c8d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,6 +1,6 @@
 {
   "name": "air-quality-mapper",
-  "version": "0.9.1",
+  "version": "0.10.0",
   "lockfileVersion": 1,
   "requires": true,
   "dependencies": {
@@ -48,6 +48,12 @@
       "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==",
       "dev": true
     },
+    "@types/event-emitter-es6": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@types/event-emitter-es6/-/event-emitter-es6-1.1.0.tgz",
+      "integrity": "sha512-A0010GUMSopVYUlDdzhYAD8Z2qGC2hG2H32IHy554NpGEHmsx8M6/lLZRRqxXCxLq28aYrmaZtGwDJjeNXbTDQ==",
+      "dev": true
+    },
     "@types/geojson": {
       "version": "7946.0.7",
       "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.7.tgz",
@@ -1120,6 +1126,11 @@
       "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=",
       "dev": true
     },
+    "event-emitter-es6": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/event-emitter-es6/-/event-emitter-es6-1.1.5.tgz",
+      "integrity": "sha1-75UxGy4Xqjm+djsDHOSvfunLeEk="
+    },
     "expand-brackets": {
       "version": "2.1.4",
       "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz",
diff --git a/package.json b/package.json
index f4de7c0..c818123 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
 	"name": "air-quality-mapper",
-	"version": "0.10",
+	"version": "0.10.0",
 	"description": "The web interface and JSON api for the ConnectedHumber Air Quality Monitoring Project.",
 	"private": true,
 	"main": "index.mjs",
@@ -22,6 +22,7 @@
 		"chroma-js": "^2.0.3",
 		"d3-delaunay": "^4.1.5",
 		"dom-create-element-query-selector": "github:hekigan/dom-create-element-query-selector",
+		"event-emitter-es6": "^1.1.5",
 		"iso8601-js-period": "^0.2.1",
 		"leaflet": "^1.5.1",
 		"leaflet-fullscreen": "^1.0.2",
@@ -37,6 +38,7 @@
 		"@types/chart.js": "^2.7.53",
 		"@types/chroma-js": "^1.4.1",
 		"@types/d3-delaunay": "^4.1.0",
+		"@types/event-emitter-es6": "^1.1.0",
 		"@types/leaflet": "^1.4.4",
 		"@types/leaflet-fullscreen": "^1.0.4",
 		"nightdocs": "^1.0.5",
