[Quick Start](https://leafletjs.com/SlavaUkraini/examples/quick-start/) [Alternative quick start](https://joshuafrazier.info/leaflet-basics/) [Mapster Mapping in LeafletJS YT Playlist](https://www.youtube.com/playlist?list=PLm76kc4VPkn27kRYq-58COO5r5bQdrKyy) ## import via CDN Follow the [Quick Start](https://leafletjs.com/SlavaUkraini/examples/quick-start/) guide to add Leaflet's CSS and JavaScript to your project and include a `<div>` with a map element. ## base map options See [here](https://leaflet-extras.github.io/leaflet-providers/preview/) for a comprehensive list of different base maps. Esri's Light Grey base map is a good, free option: ```JavaScript L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}', { attribution: 'Tiles &copy; Esri &mdash; Esri, DeLorme, NAVTEQ', maxZoom: 16 }).addTo(map); ``` Note that all ESRI basemaps use the Web Mercator Auxiliary Sphere ([docs](https://doc.arcgis.com/en/data-appliance/6.4/reference/common-attributes.htm)) which is EPSG 3857. This is also the default Leaflet CRS, however differs from the typical web projection (EPSG 4326). ## add marker The basic method for adding a marker is: ```JavaScript var marker = L.marker([51.5, -0.09]).addTo(map); ``` To add multiple markers, loop over pairs of coordinates. ## custom icon If you are using MaterialCSS, or at least have imported Google Icon Fonts, you can use custom icons in place of the standard set. See [here](https://fonts.google.com/icons) for a list of available icons. (If you are not using [[base/Google Apps Script/Google Apps Script]], you can also download images (png, svg, etc.) and pass the relative file path to the html property.) ```JavaScript const starIcon = L.divIcon({  html: '<span class="material-icons md-18">star_rate</span>',  iconSize: [18,18],  // specify size in class above  iconAnchor: [9,9],  // from top left corner  popupAnchor: [0, -18], // from icon anchor, up is negative  className: 'myDivIcon' }); // Add a marker with the custom icon​ L.marker([15.3237537, -91.47], {    icon: starIcon }).addTo(map); ``` The html property of the `myDivIcon` class is passed to the html of the `star_rate` icon in Google's icon set of the size 18 pixels (24, 36, and 48 are also options, see [here](https://developers.google.com/fonts/docs/material_icons#sizing)). We can then specify the size as 18x18 pixels to the `iconSize` property. The `iconAnchor` property tells Leaflet what point on the icon to keep in place when zooming. In this case, we want the vertical and horizontal center to stay in the same place. If using the default place marker, you would want the vertical bottom and horizontal center to stay put. Specifying the `className` helps Leaflet with styling the `<div>`, possibly by avoiding conflicts with MaterialCSS. You then pass the created `<div>` Icon to the marker class and add it to the map. ## Marker Cluster [Marker Cluster](https://github.com/Leaflet/Leaflet.markercluster) is a popular plugin for Leaflet. To use it in your project, you cannot download via CDN (as of this writing) due to a [MIME type error](https://stackoverflow.com/questions/59457683/leaflet-markercluster-plugin-not-loading). Instead, copy/paste the contents of the necessary files in the `dist` folder to `.html` files in your project and then use the custom `include` function to load the stylesheets and scripts: - `MarkerCluster.css` - `MarkerCluster.Default.css` (not needed if you use your own `iconCreateFunction` instead of the default one) - `leaflet.markercluster.js` (or `leaflet.markercluster-src.js` for the non-minified version) To call marker cluster: ```JavaScript var coords = [ [15.3237537, -91.47], [14.9360448, -91.4593998], [14.86711, -91.5394938] ] var markers = L.markerClusterGroup(); coords.forEach(function(coord){ markers.addLayer(L.marker(coord)) }) map.addLayer(markers); ``` ## popups Popups in Leaflet can be a bit unpredictable in terms of styling if using the multiple different methods available to create them, try to stick with one. ```JavaScript var markers = L.markerClusterGroup(); coords.forEach(function(coord){ html = '' markers.addLayer(L.marker(coord)) .bindpopup(html) }) ``` ## heatmap Use Leaflet.heat for heatmaps. ## GeoJSON Leaflet provides [multiple methods](https://leafletjs.com/SlavaUkraini/reference.html#geojson) for styling and filtering GeoJSON objects. Because a single GeoJSON object can contain multiple geometry types, you need to select the right method for the type of geometry you want to style. For example, the style function in the example below will only apply to point features. ```JavaScript L.geoJSON(data, {    style: function (feature) {        return {color: feature.properties.color};   } }).bindPopup(function (layer) {    return layer.feature.properties.description; }).addTo(map); ``` See the documentation for more information. There are a few options for loading GeoJSON data. Before loading, make sure the GeoJSON is in the right CRS and has no other issues, such as swapped lat/lon (there is a lack of standardization in GeoJSON). See the GeoJSON utilities below. ### import from Drive App I don't recommend this option since Drive App is a little slow to find and load the contents of a file, but there might be a better way to identify a file stored in Drive: ```JavaScript function getMunis() {  var fileName = "municipalities.geojson";  var files = DriveApp.getFilesByName(fileName);  if (files.hasNext()) {    var file = files.next();    var content = file.getBlob().getDataAsString();    var json = JSON.parse(content);    return json; } } ``` ### load from web service You can load the data from a hosted platform. My go-to would be GitHub (make sure you get the raw content). Use a JavaScript fetch call. ```JavaScript var myGeoJson = false; fetch('<url>', {    method: 'GET' }) .then(response => respons.json()) .then(json => {    console.log(json)    var geojson = L/geoJSON(json, {        style: function(feature) {            return {                fillOpacity:0           };       }   }).addTo(map);    //    map.fitBounds(geojson.getBounds()); }) .catch(error => console.log(error.message)); ``` ### store locally Maybe the fastest way to load a small GeoJSON dataset is to simply store it in the project as it's own file. Use this in your `Code.gs`: ```JavaScript title="Code.gs" function getMunis() { var jsonString = HtmlService.createHtmlOutputFromFile("my-json.html").getContent(); var jsonObject = JSON.parse(jsonString);    return jsonObject ``` ### GeoJSON utilities - GeoJSON.io (create GeoJSON from scratch) - rewind-GeoJSON (at mapster: to correct for right-hand rule) - mapshaper (convert filetypes, simplify) - Always snap vertices on import! Otherwise simplifying will result in slivers - Use the Douglas-Peucker algorithm (this matches the `geopandas.series.simplify` ([link](https://geopandas.org/en/stable/docs/reference/api/geopandas.GeoSeries.simplify.html)) method) - GeoJSONlint (for debugging geojson files) Some applications were built by coders not geographers, so lat/lon can be switched. Always check this. Leaflet expects (lat, lon). ## layer control The Layer Control is very simple to set up. See the [documentation](https://leafletjs.com/SlavaUkraini/reference.html#control-layers) and [example](https://leafletjs.com/SlavaUkraini/examples/layers-control/) for guidance. To default a layer to off, do not add it to the map (don't call `.addTo(map)` method) instead just pass it to the Layer Control. ## feature group Feature groups are helpful for collecting different objects into a single layer group. You can use Feature Groups for legend control, binding popups and tooltips, and setting event listeners. ```JavaScript a = L.marker(...) b = L.marker(...) c = L.marker(...) var myFeatureGroup = L.featureGroup([a, b, c]) ``` You can iterate through the layers in a feature group with `eachLayer` method ```JavaScript.html myFeatureGroup.eachLayer(function(layer){    console.log(layer) }) ``` ## ​bounds Use bounds to auto calculate zoom and extent based on data on the map. It's best to use a Feature Group to simplify calculation of the bounds. ```JavaScript map.fitbounds(layer.getBounds()) ``` ## events Leaflet has special methods to handle events on the map. See the documentation for the type of Leaflet object you want to bind an event to, there you will find the specific instructions for that event. In this example, we'll highlight the marker where the user is hovering by swapping its icon from a black icon to a red icon. ```JavaScript redIcon = L.icon(...) blackIcon = L.icon(...) var markers = [] var coordinates = [[...]] coordinates.forEach(function(coords){ var maker = L.marker(coords, { icon: blackIcon; }).on('mousemove', function(e) { e.target.setIcon(redIcon); }).on('mouseout', function(e) { e.target.setIcon(blackIcon); }) }) ``` Use console.log to understand what you are getting back from an event. ```JavaScript map.on('mousemove', function(e) { console.log(e) }) ``` ## layer drawing order The drawing order of layers may not be the same as the order in your scripts if you are fetching data from services. You can set the z-order of layers to manage drawing order, but if you have many layers it might be hard to keep track of the z values you've already used. Instead, you can use `layer.bringToFront()`. ## interactions ### Filter #### Typeahead This will filter based on whether a feature contains the text input provided by a user. ```JavaScript document.addEventHandler('keyup', '#search', function(e) {    var userInput = e.target.value;    myGeoJSON.eachLayer(function(layer) {        if(layer.feature.properties.title.toLowerCase().indexOf(userInput.toLowerCase())>-1) {            layer.addTo(map);       } else {            map.removeLayer(layer);       }        console.log(layer);   }) }) ``` #### Range Slider ### Dropdown - Use `<select>` element, with initialization as blank (no option selected) - Pass from filter to map - Pass from map to filter ```HTML <body>    <select>        <option "">(No Option Selected)</option>    </select> </body> ///loop through options and add as html elements ​ ///create event handler ​ ///filter ​ /// set up mouseover event handler ``` ### Multiple filters ```JavaScript var filters = { text: '', range: [] }; ​ var min = 0; var max = 0; filters.range = [min, max] .on('slide', function(e) {    filters.text = e.target.value; }) ​ /// union filters, for exclusive search all must be true function filterGeoJSON(layer) {    var isItFound = false;    if(layer.feature.properties.title.toLowerCase().indexOf(filters.text.toLowerCase()){       isItFound = true;       })    if /// second condition    if (isItFound) {        layer.addTo(map);   } else {        map.removeLayer(layer);   }         } ``` ## User Inputs - `Leaflet.draw` is recommended, but many libraries are available (`leafletpm` is one to watch) - simple user inputs can be handled by map events ```JavaScript map.on('dblclick', function(e) { var newMarker = L.marker(e.latlng).addTo(map); /// other stuff }) ``` ## Mouseover Calculations - Use turf.js to get points within a polygon, need array of points and array representing bounds of the polygon ## Search bar To create a search bar for users to find locations by search, the [Leaflet Control Geocoder](https://www.liedman.net/leaflet-control-geocoder/) is a great free option that uses the open source Nominatim project but it lacks the auto-complete features and is less robust than the [Esri Leaflet geocoder](https://esri.github.io/esri-leaflet/examples/geocoding-control.html). To use the Esri Leaflet geocoder, you will need an API key and [store it securely](#hide-api-key) in your client-side code. You can set up a free Developer account to get access to the free tier, which should be sufficient for most small applications. You don't need a credit card for this account, so at worst they will turn off your app once it hits quotas. ```JavaScript // create search control const searchControl = L.esri.Geocoding.geosearch({    position: "topright",    placeholder: "",    useMapBounds: true,    providers: [        L.esri.Geocoding.arcgisOnlineProvider({            apikey: apiKey,            nearby: {                lat: 14.683945,                lng: -90.542282           }       })   ] }).addTo(map); ​ var results = L.layerGroup().addTo(map); ​ searchControl.on('results', function (data) {    results.clearLayers();    for (var i = data.results.length - 1; i >= 0; i--) {        results.addLayer(L.marker(data.results[i].latlng)       .bindPopup(data.results[i].properties.LongLabel)); // Add popup with results detail        console.log(data)   } }); ``` This is very similar to the QuickStart guide code, however note that I logged the data object returned by the search to find a property that I can bind to a popup so that the point has additional information for the user. I've also customized the icon used. ## big data Loading anything larger than 1MB can slow browsers, and anything larger than 10MB is probably prohibitive for most users. - Encoding polygons (mapbox/polyline): converts individual polygons to ASCII that can be decoded by the browser but is significantly smaller in loading. - Mapshaper: you can simplify polygons (delete vertices) to reduce the size of the file - Mapbox: you can serve as a tiled baselayer ## converting and projecting - See Mapshaper's wiki to check out their command line tools - Use Geopandas ## Other Plugins All Leaflet plugins can be found on the official website [here](https://leafletjs.com/SlavaUkraini/plugins.html). - Heatmap - leaflet-choropleth - FeatureGroup.SubGroup - Markercluster - HTMLLegend - Proj4Leaflet - Leaflet Vector Layers - Turf.js (geospatial analysis in browsers) - three.js (3D terrain) - [Leaflet-sidebar-v2](https://github.com/noerw/leaflet-sidebar-v2), ## data providers Use JavaScript's `fetch` method to get back json from a web service to quickly grab data and configure it for mapping. - Earthquake.usgs.gov: provides real-time earthquake data in GeoJSON format. - Twitter API: get information about places from Twitter (requires account). ## Map Application Design - May need to develop parallel workflows for mobile and desktop users - Use overlays, sliding panels, popups, modals and other features to maximize the map while providing additional detail on demand - Be judicious in use of interaction to avoid overwhelming the user - Full screen maps are common and maximize map functionality. Combine with overlays and information panels to provide additional information and interactivity. ### Color Schemes - Start with black and white, add color judiciously once all elements designed - Color picker: [https://www.w3schools.com/colors/colors_picker.asp](https://www.w3schools.com/colors/colors_picker.asp) Green: `#82AA9F` DIS Purple: `#6946BB` ## overlays Overlays are useful for legends and user interactions like layer controls and basic filtering/searching. These will stay on top of the map in the same location relative to the screen. Don't cramp the map with overlays. If you need to display more, consider an information panel. Position absolute, increase z-index to overlay on the map, add padding, background, margin, alignment, box-shadow, etc. ```HTML <!-- Form Overlay--> <div class="container" id="overlay" > Overlay <!--replace with content--> </div> <style> #overlay { display: block; position: absolute; z-index: 1000; background: white; opacity: 0.8; top: 0; left: 0; } .myForm { position: fixed; width: 80%; z-index: 1000; background: white; opacity: 0.9; top: 50px; left: 0; right: 0; border-radius: 5px; cursor: pointer; max-width: 700px } </style> ``` You'll need to hide the form after initial load. ```JavaScript // hide form on initial load document.getElementById('form').style.display = 'none'; ``` ## modals I chose to use the floating action button to launch a modal that will serve as the form container. The trick to getting this to work is to assign the class `modal-trigger` to the action button and set the `href` property to `#formModal`, the id of the modal you want to launch. ## integration with Google Apps Script ```JavaScript title="Code.gs" function getCoords(){ var ss = SpreadsheetApp.openById(ss_id); var ws = ss.getSheetByName('data'); var coords = ws.getRange().getValues(); // remove blank and N/A coordinates for(let i=0; i<coords[0].length; i++) { if(coords.every(col => col[i] =='' || col[i] == "#N/A")) { for(const col of coords) col.splice(i, 1); i--; } } Logger.log(coords); return coords } ``` ```JavaScript title="JavaScript.html" document.addEventListener('DOMContentLoaded', function(){ updateMap(); }) function mapActivityLocations(coords){ console.log(coords) var markers = L.markerClusterGroup(); for (var i in coords) { markers.addLayer(L.marker(coords[i], {icon: starIcon})) } map.addLayer(markers); } function updateMap(){ google.script.run.withSuccessHandler(mapActivityLocations).getCoords(); } ``` ## Joining GeoJSON with Google Sheets We want to join the data read in from Google Sheets with the GeoJSON data to create a choropleth that shows the desired data. Here we'll have two asynchronous calls to fetch the GeoJSON and read from the spreadsheet. Then we'll combine the two. Typically we would use an `async` function for this job, see official guidance [here](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function) or using what's called a promise chain (which async replaced) [here.](https://ramblings.mcpher.com/gassnippets2/organizing-asynchronous-calls-to-google-script-run/) However, for this exercise we'll use a simple pattern to store the geoJSON data into a global variable and then grab it after the user specifies the data to join from. We're relying on the user to select a dataset more slowly than the time it takes to return the geoJSON from the server. ```JavaScript // store municipality layer var muniGeoJson; ​ // setGeoJSON function setGeoJson(geoJson){  muniGeoJson = JSON.parse(JSON.stringify(geoJson));  // store data }; ​ // fetch GeoJson function fetchGeoJson(src, func){  fetch(src, {    method: 'GET' }) .then(response => response.json()) .then(json => {    func(json); }) .catch(error => console.log(error.message));  // END FETCH }; ​ ​ // map Municipalities function mapMunicipalities(json){  muniGeoJson = L.geoJSON(json, {    style: function(feature) {      return {color: 'grey', weight: 0.5, fillOpacity: 0};   }, }).addTo(map) }; ​ ​ // load DOM document.addEventListener('DOMContentLoaded', function(){  // map municipalities  fetchGeoJson(<url>, setGeoJson) }); ``` ​ Note this is similar to the pattern we used in the [[Develop a web app with Google Apps Script and Bootstrap]] tutorial, but here we use the fetch API rather than the Google Apps Script `withSuccessHandler` to fetch the data. Next, we'll fetch the spreadsheet data using sum dummy variables for testing. Helper function to get select columns from 2d array ```JavaScript // helper function to get specific columns from a 2d array function extractDataByIndices(arr,indexArr) {  return arr.map(obj => {        return obj.filter((ob,index) => {          if(indexArr.includes(index)){return ob}     }) }) } ```