Categories
Data Intranets Open Source Social Learning

Get Geospatial with the Moodle Database Activity

Moodle allows you to quickly build a custom database, including a location field; this article will show you how to make it easy for your users to populate that field, and then generate a beautiful, interactive map from its data

Moodle is the best-of-breed open-source Learning Management System used by most universities, and is making strides into the enterprise intranet space – so organisations and their members can better track their continuing professional development. In addition to its state-of-the-art course management features, Moodle allows you to quickly build a custom database activity, including a location field; this article will show you how to make it easy for your users to populate that field, and then generate a beautiful, interactive map from its data…

I was working with a client who needed a ‘Lessons Learnt’ application where staff could register improvements which others could reuse – or mishaps to prevent others repeating. Being able to navigate the lessons by location was a key requirement, and using freely available code we were able to deliver a delightful solution without customising the source code of Moodle.

Listen on, dear reader, as I take you through the steps to implement a location picker on the ‘Add entry’ form, and a clustered, clickable map on the ‘View list’ page.

Add the location field to your database

This step is easy; on the ‘Fields’ tab choose ‘Latlong’ from the ‘Add field’ drop-down list:

Once you’ve selected ‘latlong’, give the field a name, optional description, and save.

Add a location picker to the ‘Add item’ form

The ‘Location’ field, when added to the ‘Add entry’ form, generates two boxes where users can enter the item’s latitude and longitude:

Figuring out what a location’s lat/long coordinates are without a picker is tricky for end users, however; what we’re after is a location picker which allows users to move and zoom around a map, find a location, and click on it to automatically update the location fields:

To achieve this on the form, follow these four easy steps on the three tabs below:

  1. Go to the ‘Add entry’ tab and find the IDs of the input fields on the form by pressing ‘F12’, clicking on the input field, and noting the id="" values:

2. Head to the ‘Add template’ tab and paste the following code into the top of it:

<link rel="stylesheet" href="https://unpkg.com/leaflet@1.7.1/dist/leaflet.css"    integrity="sha512-xodZBNTC5n17Xt2atTPuE1HxjVMSvLVW9ocqUKLsCC5CXdbqCmblAshOMAS6/keqq/sMZMZ19scR4PsZChSR7A=="  crossorigin=""/>
<script src="https://code.jquery.com/jquery-2.2.4.min.js" integrity="sha256-BbhdlvQf/xTY9gja0Dq3HiwQF8LaCRTXxZKRutelT44=" crossorigin="anonymous"></script>
<script src="https://unpkg.com/leaflet@1.7.1/dist/leaflet.js"    integrity="sha512-XQoYMqMTK8LvdxXYG3nZ448hOEQiglfqkJs1NOQV44cWnUrBc8PkAOcXy20w0vlaXaVUearIOBhiXZ5V3ynxwA=="  crossorigin=""></script>
<script>!function(t){if("function"==typeof define&&define.amd)define(["jquery","leaflet"],t);else if("undefined"!=typeof module)module.exports=t(require("jquery","leaflet"));else{if(void 0===window.jQuery)throw"jQuery must be loaded first";if(void 0===window.L)throw"Leaflet must be loaded first";t(window.jQuery,window.L)}}(function(t,e){var o=t;o.fn.leafletLocationPicker=function(t,a){var n={OSM:window.location.protocol+"//{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"},i={zoom:5,center:e.latLng([-9.438445,-74.950571]),zoomControl:!1,attributionControl:!1};o.isPlainObject(t)&&o.isPlainObject(t.map)&&(i=o.extend(i,t.map));var l={alwaysOpen:!1,className:"leaflet-locpicker",location:i.center,locationFormat:"{lat}{sep}{lng}",locationMarker:!0,locationDigits:6,locationSep:",",position:"topright",layer:"OSM",height:400,width:300,event:"click",cursorSize:"30px",map:i,onChangeLocation:o.noop,mapContainer:""};function r(a){var n=a;switch(o.type(a)){case"string":var i=a.split(t.locationSep);n=i[0]&&i[1]?e.latLng(i):null;break;case"array":n=e.latLng(a);break;case"object":var l,r;a.hasOwnProperty("lat")?l=a.lat:a.hasOwnProperty("latitude")&&(l=a.latitude),a.hasOwnProperty("lng")?r=a.lng:a.hasOwnProperty("lon")?r=a.lon:a.hasOwnProperty("longitude")&&(r=a.longitude),n=e.latLng(parseFloat(l),parseFloat(r));break;default:n=a}return function(o){return o?e.latLng(parseFloat(o.lat).toFixed(t.locationDigits),parseFloat(o.lng).toFixed(t.locationDigits)):o}(n)}function p(a){if(a.divMap=document.createElement("div"),a.$map=o(document.createElement("div")).addClass(t.className+"-map").height(t.height).width(t.width).append(a.divMap),t.mapContainer&&o(t.mapContainer)?a.$map.appendTo(t.mapContainer).addClass("map-select"):a.$map.appendTo("body"),a.location&&(t.map.center=a.location),"string"==typeof t.layer&&n[t.layer]?t.map.layers=e.tileLayer(n[t.layer]):t.layer instanceof e.TileLayer||t.layer instanceof e.LayerGroup?t.map.layers=t.layer:t.map.layers=e.tileLayer(n.OSM),a.map=e.map(a.divMap,t.map).addControl(e.control.zoom({position:"bottomright"})).on(t.event,function(t){a.setLocation(t.latlng)}),t.activeOnMove&&a.map.on("move",function(t){a.setLocation(t.target.getCenter())}),!0!==t.alwaysOpen){var i=e.control({position:"topright"});i.onAdd=function(o){var n=e.DomUtil.create("div","leaflet-bar"),i=e.DomUtil.create("a","leaflet-control "+t.className+"-close");return i.innerHTML="&times;",n.appendChild(i),e.DomEvent.on(i,"click",e.DomEvent.stop,a).on(i,"click",a.closeMap,a),n},i.addTo(a.map)}var l,p;return t.locationMarker&&(a.marker=(l=a.location,p="padding: 0px; margin: 0px; position: absolute; outline: 1px solid #fff; box-shadow: 1px 1px 3px rgba(0, 0, 0, 0.6);",e.marker(r(l)||e.latLng(0,0),{icon:e.divIcon({className:t.className+"-marker",iconAnchor:e.point(0,0),html:'<div style="position: relative; top: -1px; left: -1px; padding: 0px; margin: 0px; cursor: crosshair;width: '+t.cursorSize+"; height: "+t.cursorSize+';"><div style="width: 50%; height: 0px; left: -70%; border-top:  2px solid black;'+p+'"></div><div style="width: 50%; height: 0px; left:  30%; border-top:  2px solid black;'+p+'"></div><div style="width: 0px; height: 50%; top:   30%; border-left: 2px solid black;'+p+'"></div><div style="width: 0px; height: 50%; top:  -70%; border-left: 2px solid black;'+p+'"></div></div>'})})).addTo(a.map)),a.$map}return t=o.isPlainObject(t)?o.extend(l,t):o.isFunction(t)?o.extend(l,{onChangeLocation:t}):l,o.isFunction(a)&&(t=o.extend(l,{onChangeLocation:a})),o(this).each(function(a,n){var i=this;i.$input=o(this),i.locationOri=i.$input.val(),i.onChangeLocation=function(){var e={latlng:i.location,location:i.getLocation()};i.$input.trigger(o.extend(e,{type:"changeLocation"})),t.onChangeLocation.call(i,e)},i.setLocation=function(t,e){t=t||l.location,i.location=r(t),i.marker&&i.marker.setLatLng(t),e||(i.$input.data("location",i.location),i.$input.val(i.getLocation()),i.onChangeLocation())},i.getLocation=function(){return i.location?e.Util.template(t.locationFormat,{lat:i.location.lat,lng:i.location.lng,sep:t.locationSep}):i.location},i.updatePosition=function(){switch(t.position){case"bottomleft":i.$map.css({top:i.$input.offset().top+i.$input.height()+6,left:i.$input.offset().left});break;case"topright":i.$map.css({top:i.$input.offset().top,left:i.$input.offset().left+i.$input.width()+5})}},i.openMap=function(){i.updatePosition(),i.$map.show(),i.map.invalidateSize(),i.$input.trigger("show")},i.closeMap=function(){i.$map.hide(),i.$input.trigger("hide")},i.setLocation(i.locationOri,!0),i.$map=p(i),i.$input.addClass(t.className).on("focus."+t.className,function(t){t.preventDefault(),i.openMap()}).on("blur."+t.className,function(t){t.preventDefault();for(var e=t.relatedTarget,o=!0;e;){if(e._leaflet){o=!1;break}e=e.parentElement}o&&setTimeout(function(){i.closeMap()},100)}),o(window).on("resize",function(){i.$map.is(":visible")&&i.updatePosition()}),t.alwaysOpen&&!0===t.alwaysOpen&&i.openMap()}),this}});
</script>

3. Paste the following into the bottom of the ‘Add entry’ template, changing the field_#_# values with the ID’s you found in step 1 above:

<div id="picker" style="border: 1px solid black; height: 400px; width: 300px;"></div>
<script>
$('#picker').leafletLocationPicker({
		locationSep: ' - ',
		alwaysOpen: true,
		mapContainer: "#picker"
	})
	.on('changeLocation', function(e) {
document.getElementById("field_#_#").value = e.latlng.lat;
document.getElementById("field_#_#").value = e.latlng.lng;
	});
</script>

4. Add the following code to the ‘CSS template’ tab:

#mapid{height:400px}.leaflet-locpicker-active{box-shadow:0 0 5px rgba(0,0,0,.3)}.leaflet-locpicker-map{display:none;position:absolute;background:#fff;box-shadow:4px 4px 8px rgba(0,0,0,.2)}.leaflet-locpicker-marker{cursor:crosshair}.leaflet-locpicker-close{cursor:pointer;width:30px;height:30px;line-height:30px;font:700 22px 'Lucida Console',Monaco,monospace;text-indent:1px}.leaflet-locpicker-map .leaflet-container{position:absolute;top:5px;right:5px;bottom:5px;left:5px;cursor:crosshair;border:1px solid rgba(100,100,100,.2)}.leaflet-locpicker-map .leaflet-control{box-shadow:none;margin:0}.leaflet-locpicker-map .leaflet-bar{border-radius:0}.leaflet-locpicker-map .leaflet-bar a:first-child{border-top-left-radius:0;border-top-right-radius:0}.leaflet-locpicker-map .leaflet-bar a:last-child{border-bottom-left-radius:0;border-bottom-right-radius:0}.map-select{display:block!important;position:relative;left:0!important;top:0!important;width:100%!important;margin:0 auto}

You should now have a location picker in your ‘Add entry’ form; you may want to play around with the positioning of it in the ‘Add template’ and the ‘CSS template’ tabs.

Add a map view to the ‘List view’ page

Once the location field is populated, when you view the item – either in list or single item view – the field becomes a link which, when clicked on, takes you to it’s location on a map.

The feature with so much hidden potential is the field updates a dynamic KML file in the database settings, and that’s where things get really interesting. How can we convert that KML file into a map view of our database items?

  1. First off, we’ll need a free Mapbox token. Create an account if you don’t have one already, then head to your Access Tokens page. Copy the default token.

2. Head to the ‘Fields’ tab, click on the location field, and choose your entry title in the ‘How to label items in KML files’. Then right-click on the KML link, and ‘Copy link location’:

3. Head to the ‘View list’ template, and paste the following code into the bottom of it, replacing:

  • pk.xxx with the token you snaffled in Step 1 above
  • https://your-moodle-site-database-kml-link with the link you copied in Step 2 above
  • The default view coordinates and zoom level I’ve set below to Greenwich and at around a ‘country’ zoom level of ‘6’
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.7.1/dist/leaflet.css"    integrity="sha512-xodZBNTC5n17Xt2atTPuE1HxjVMSvLVW9ocqUKLsCC5CXdbqCmblAshOMAS6/keqq/sMZMZ19scR4PsZChSR7A=="  crossorigin=""/>
<link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.markercluster/0.4.0/MarkerCluster.css" />
<link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.markercluster/1.4.1/MarkerCluster.Default.css" />
<script src="https://unpkg.com/leaflet@1.7.1/dist/leaflet.js"    integrity="sha512-XQoYMqMTK8LvdxXYG3nZ448hOEQiglfqkJs1NOQV44cWnUrBc8PkAOcXy20w0vlaXaVUearIOBhiXZ5V3ynxwA=="  crossorigin=""></script>
<script src='https://api.tiles.mapbox.com/mapbox.js/plugins/leaflet-omnivore/v0.3.1/leaflet-omnivore.min.js'></script>
<script type='text/javascript' src='https://cdnjs.cloudflare.com/ajax/libs/leaflet.markercluster/1.4.1/leaflet.markercluster.js'></script>
<div id="mapid"></div>
<script>
var mymap = L.map('mapid').setView([51.477719, 0.0000], 6); L.tileLayer('https://api.mapbox.com/styles/v1/{id}/tiles/{z}/{x}/{y}?access_token=pk.xxx', {
		maxZoom: 18,
		attribution: 'Map data &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, ' +
			'Imagery © <a href="https://www.mapbox.com/">Mapbox</a>',
		id: 'mapbox/streets-v11',
		tileSize: 512,
		zoomOffset: -1
	}).addTo(mymap);
var markers = L.markerClusterGroup();	
var runLayer = omnivore.kml('https://your-moodle-site-database-kml-link', markers)
    .on('ready', function() {
		markers.addLayer(runLayer);
		mymap.fitBounds(runLayer.getBounds());
		runLayer.eachLayer(function(layer) {
		layer.bindPopup('<h4>'+layer.feature.properties.name+'</h4><p>'+layer.feature.properties.description+'</p>')
        });
    })
    markers.addTo(mymap);
</script>

Save, and head to the ‘View list’ page. Voila! You should now be able to see the map with entries clustered, any entries with duplicate locations will spider out, and clicking on a placeholder should provide the title and a link to the entry.

Leave a Reply