Tethys Platform
Table Of Contents
Table Of Contents

Visualize THREDDS Services with Leaflet

Last Updated: December 2019

In this tutorial you will learn how to add a Leaflet map to a Tethys App for visualizing layers from a THREDDS server. This tutorial is adapted from Time Dimension Example 1 and the Siphon NCSS Time Series Example. The following topics will be covered in this tutorial:

../../_images/visualize_leaflet_solution.png

0. Start From Previous Solution (Optional)

If you wish to use the previous solution as a starting point:

git clone https://github.com/tethysplatform/tethysapp-thredds_tutorial.git
cd tethysapp-thredds_tutorial
git checkout -b thredds-service-solution thredds-service-solution-3.0

1. Add Leaflet Map to Home View

Leaflet is not officially supported by Tethys Platform as a Gizmo, but it can easily be added manually as follows:

  1. Include the Leaflet libraries in your app. Leaflet can be added a number of different ways as documented on their Download page. For this tutorial use the CDN option. Replace the contents of templates/thredds_tutorial/home.html with:

{% extends "thredds_tutorial/base.html" %}
{% load tethys_gizmos %}

{% block styles %}
  {{ block.super }}
  <link rel="stylesheet" href="https://unpkg.com/leaflet@1.6.0/dist/leaflet.css"
   integrity="sha512-xwE/Az9zrjBIphAcBb3F6JVqxf46+CDLwfLMHloNu6KEQCAWi6HcDUbeOfBIptF7tcCzusKFjFw2yuvEpDL9wQ=="
   crossorigin=""/>
{% endblock %}

{% block global_scripts %}
  {{ block.super }}
  <script src="https://unpkg.com/leaflet@1.6.0/dist/leaflet.js"
   integrity="sha512-gZwIG9x3wUXg2hdXF6+rVkLF/0Vi9U8D2Ntg4Ga5I5BZpVkVxlJWbSQtXPSiUTtC0TjtGOmxa1AJPuV0CPthew=="
   crossorigin=""></script>
{% endblock %}

{% block header_buttons %}
{% endblock %}

{% block app_content %}
  <div id="leaflet-map"></div>
{% endblock %}

{% block app_actions_override %}
{% endblock %}
  1. Write a bit of JavaScript to initialize the map using the JavaScript closure pattern for organization. Create a method called init_map in public/js/leaflet_map.js with the following contents:

/*****************************************************************************
 * FILE:      Leaflet Map Module for THREDDS Tutorial
 * DATE:      13 December 2019
 * AUTHOR:    Nathan Swain
 * COPYRIGHT: (c) Aquaveo 2019
 * LICENSE:   BSD 2-Clause
 *****************************************************************************/

/*****************************************************************************
 *                      LIBRARY WRAPPER
 *****************************************************************************/

var LEAFLET_MAP = (function() {
    "use strict"; // And enable strict mode for this library

    /************************************************************************
    *                      MODULE LEVEL / GLOBAL VARIABLES
    *************************************************************************/
    var public_interface,                           // Object returned by the module
        m_map;                                              // The Leaflet Map
    /************************************************************************
    *                    PRIVATE FUNCTION DECLARATIONS
    *************************************************************************/
    // Map Methods
    var init_map;

    /************************************************************************
    *                    PRIVATE FUNCTION IMPLEMENTATIONS
    *************************************************************************/
    // Map Methods
    init_map = function() {
        // Create Map
        m_map = L.map('leaflet-map', {
            zoom: 3,
            center: [0, 0],
            fullscreenControl: true,
        });

        // Add Basemap
        L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
            attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
        }).addTo(m_map);
    };

    /************************************************************************
    *                        DEFINE PUBLIC INTERFACE
    *************************************************************************/
    /*
     * Library object that contains public facing functions of the package.
     * This is the object that is returned by the library wrapper function.
     * See below.
     * NOTE: The functions in the public interface have access to the private
     * functions of the library because of JavaScript function scope.
     */
    public_interface = {};

    /************************************************************************
    *                  INITIALIZATION / CONSTRUCTOR
    *************************************************************************/

    // Initialization: jQuery function that gets called when
    // the DOM tree finishes loading
    $(function() {
        init_map();
    });

    return public_interface;

}()); // End of package wrapper
  1. Remove the padding around the content area and override the styles for the app actions area so that the map fills the content area. Create public/css/leaflet_map.css with the following contents:

/* Map Format */
#app-content-wrapper #app-content {
    height: 100%;
}

#inner-app-content {
    height: 100%;
    padding: 0;
}

#leaflet-map {
    height: 100%;
}

/* Remove padding on bottom where app-actions section used to be */
#app-content-wrapper #app-content {
    padding-bottom: 0;
}
  1. Include the new stylesheet and JavaScript modules in templates/thredds_tutorial/home.html:

{% extends "thredds_tutorial/base.html" %}
{% load tethys_gizmos static %}

{% block styles %}
  {{ block.super }}
  <link rel="stylesheet" href="https://unpkg.com/leaflet@1.6.0/dist/leaflet.css"
   integrity="sha512-xwE/Az9zrjBIphAcBb3F6JVqxf46+CDLwfLMHloNu6KEQCAWi6HcDUbeOfBIptF7tcCzusKFjFw2yuvEpDL9wQ=="
   crossorigin=""/>
  <link rel="stylesheet" href="{% static 'thredds_tutorial/css/leaflet_map.css' %}"/>
{% endblock %}

{% block global_scripts %}
  {{ block.super }}
  <script src="https://unpkg.com/leaflet@1.6.0/dist/leaflet.js"
   integrity="sha512-gZwIG9x3wUXg2hdXF6+rVkLF/0Vi9U8D2Ntg4Ga5I5BZpVkVxlJWbSQtXPSiUTtC0TjtGOmxa1AJPuV0CPthew=="
   crossorigin=""></script>
{% endblock %}

{% block scripts %}
  {{ block.super }}
  <script src="{% static 'thredds_tutorial/js/leaflet_map.js' %}" type="text/javascript"></script>
{% endblock %}

Tip

Load the static library and use the static tag to reference scripts, stylesheets, and other resources in your public directory.

  1. Remove superfluous navigation links in templates/thredds_tutorial/base.html:

{% block app_navigation_items %}
{% endblock %}
  1. Verify that the Leaflet map is now in the app. Browse to http://localhost:8000/apps/thredds-tutorial in a web browser and login if necessary. The leaflet map should appear in the content area of the app and fill it.

2. Create Controls for Selecting Datasets

In this step, you'll create controls to allow the user to search for and select a dataset and variable to visualize on the map. THREDDS WMS services provide a number of color ramps and styles out-of-the-box. You'll also create a control for changing the style of the layer.

  1. Define gizmos for the dataset selection controls in the home controller of controllers.py. Replace the contents of controllers.py with:

from django.shortcuts import render
from tethys_sdk.permissions import login_required
from tethys_sdk.gizmos import SelectInput


@login_required()
def home(request):
    """
    Controller for the app home page.
    """
    # Retrieve dataset options from the THREDDS service
    datasets = []

    dataset_select = SelectInput(
        display_text='Dataset',
        name='dataset',
        multiple=False,
        options=datasets,
        initial=None,
        select2_options={'placeholder': 'Select a dataset',
                         'allowClear': False}
    )

    variable_select = SelectInput(
        display_text='Variable',
        name='variable',
        multiple=False,
        options=(),
        select2_options={'placeholder': 'Select a variable',
                         'allowClear': False}
    )

    style_select = SelectInput(
        display_text='Style',
        name='style',
        multiple=False,
        options=(),
        select2_options={'placeholder': 'Select a style',
                         'allowClear': False}
    )

    context = {
        'dataset_select': dataset_select,
        'variable_select': variable_select,
        'style_select': style_select,
    }
    return render(request, 'thredds_tutorial/home.html', context)
  1. Add the controls to the app_navigation_items block in templates/thredds_tutorial/home.html:

{% block app_navigation_items %}
  <li class="title">Query</li>
  {% gizmo dataset_select %}
  {% gizmo variable_select %}
  {% gizmo style_select %}
{% endblock %}
  1. Left align the section titles in the navigation by adding the following to public/css/main.css:

#app-content-wrapper #app-content #app-navigation .nav li.title {
    padding-left: 0;
}
  1. Verify that the controls are in the app and functioning properly. Browse to http://localhost:8000/apps/thredds-tutorial in a web browser and login if necessary. There should be three controls in the app navigation area on the left: Dataset, Variable, and Style.

Tip

If the app navigation window is not open, use the hamburger menu next to the app icon to open it.

3. Initialize Dataset Select Control

At this point the select controls are empty and don't do anything. In this step, you'll query the THREDDS service to populate the dataset select control with a list of available datasets to visualize. You'll narrow the query to only those datasets that have the WMS service enabled.

  1. Create a new Python module thredds_methods.py with the following contents:

def parse_datasets(catalog):
    """
    Collect all available datasets that have the WMS service enabled.

    Args:
        catalog(siphon.catalog.TDSCatalog): A Siphon catalog object bound to a valid THREDDS service.

    Returns:
        list<2-tuple<dataset_name, wms_url>: One 2-tuple for each dataset.
    """
    datasets = []

    for dataset_name, dataset_obj in catalog.datasets.items():
        dataset_wms_url = dataset_obj.access_urls.get('wms', None)
        if dataset_wms_url:
            datasets.append((dataset_name, f'{dataset_name};{dataset_wms_url}'))

    for catalog_name, catalog_obj in catalog.catalog_refs.items():
        d = parse_datasets(catalog_obj.follow())
        datasets.extend(d)

    return datasets

Note

This function is recursive, meaning it calls itself. Since THREDDS datasets can be located at arbitrary paths, sometimes nested in deep folder hierarchies, the function needs to be able to follow the paths down to find all the datasets. In this case, it searches for both datasets and new catalogs. When it encounters a new catalog, it calls itself again, initiating a search for dataset and new catalogs at that level. The dataset are collected and returned back up the call stack.

  1. Modify the home controller in controllers.py to call the parse_datasets function to get a list of all datasets available on the THREDDS service:

from django.shortcuts import render
from tethys_sdk.permissions import login_required
from tethys_sdk.gizmos import SelectInput
from .app import ThreddsTutorial as app
from .thredds_methods import parse_datasets
@login_required()
def home(request):
    """
    Controller for the app home page.
    """
    catalog = app.get_spatial_dataset_service(app.THREDDS_SERVICE_NAME, as_engine=True)

    # Retrieve dataset options from the THREDDS service
    print('Retrieving Datasets...')
    datasets = parse_datasets(catalog)
    initial_dataset_option = datasets[0]
    from pprint import pprint
    pprint(datasets)
    pprint(initial_dataset_option)

    dataset_select = SelectInput(
        display_text='Dataset',
        name='dataset',
        multiple=False,
        options=datasets,
        initial=initial_dataset_option,
        select2_options={'placeholder': 'Select a dataset',
                         'allowClear': False}
    )

    ...

Tip

If you encounter HTTPS/SSL verification issues (e.g. due to using a self-signed SSL certificate during development), you may want to disable SSL verification of the THREDDS catalog engine. To do so, import the Siphon session manager and then set the verify setting to False before retrieving your catalog engine:

from siphon.http_url import session_manager
session_manager.set_session_options(verify=False)
catalog = app.get_spatial_dataset_service('my_thredds_service', as_engine=True)

Warning

DO NOT DISABLE SSL VERIFICATION FOR APPS IN PRODUCTION.

  1. Verify that home controller is using the new parse_dataset function to find THREDDS datasets. Browse to http://localhost:8000/apps/thredds-tutorial in a web browser and login if necessary. After the home page loads, inspect the log messages in the terminal where Tethys is running. The pprint calls in our controller should print the object being returned from the parse_dataset function in the terminal. It should also populate the options for the Dataset control.

4. Create Endpoint for Getting Available WMS Layers

Each time a new dataset is selected, the options in the variable and style controls need to be updated to match the variables and styles of the new dataset. This information can be found by querying the WMS endpoint of the dataset provided by THREDDS. Querying the WMS endpoint is most easily accomplished by using the OWSLib Python library. In this step you will implement a new controller that will use OWSLib to retrieve the information and call it using AJAX anytime a new dataset is selected.

  1. Add the following get_layers_for_wms function to thredds_methods.py:

from owslib.wms import WebMapService
def get_layers_for_wms(wms_url):
    """
    Retrieve metadata from a WMS service including layers, available styles, and the bounding box.

    Args:
        wms_url(str): URL to the WMS service endpoint.

    Returns:
        dict<layer_name:dict<styles,bbox>>: A dictionary with a key for each WMS layer available and a dictionary value containing metadata about the layer.
    """
    wms = WebMapService(wms_url)
    layers = wms.contents
    from pprint import pprint
    print('WMS Contents:')
    pprint(layers)

    layers_dict = dict()
    for layer_name, layer in layers.items():
        layer_styles = layer.styles
        layer_bbox = layer.boundingBoxWGS84
        leaflet_bbox = [[layer_bbox[1], layer_bbox[0]], [layer_bbox[3], layer_bbox[2]]]
        layers_dict.update({
            layer_name: {
                'styles': layer_styles,
                'bbox': leaflet_bbox
            }
        })

    print('Layers Dict:')
    pprint(layers_dict)
    return layers_dict

Tip

If you encounter HTTPS/SSL verification issues (e.g. due to using a self-signed SSL certificate during development), you may want to disable SSL verification of the WebMapService engine. To do so, import the OWSLib Authentication class and create an auth object with verify set to False. Then pass this auth object to the WebMapService constructor:

from owslib.util import Authentication
auth = Authentication(verify=False)
wms = WebMapService(wms_url, auth=auth)

Note

At the time of writing there was an open issue with the verify parameter of an Authentication object being negated when set to False, making this work around not work. See: OWSLib Issue 609.

Warning

DO NOT DISABLE SSL VERIFICATION FOR APPS IN PRODUCTION.

  1. Create the get_wms_layers controller in controllers.py:

from django.http import HttpResponseNotAllowed, JsonResponse
from .thredds_methods import parse_datasets, get_layers_for_wms
@login_required()
def get_wms_layers(request):
    json_response = {'success': False}

    if request.method != 'GET':
        return HttpResponseNotAllowed(['GET'])

    try:
        wms_url = request.GET.get('wms_url', None)

        print(f'Retrieving layers for: {wms_url}')
        layers = get_layers_for_wms(wms_url)

        json_response.update({
            'success': True,
            'layers': layers
        })

    except Exception:
        json_response['error'] = f'An unexpected error has occurred. Please try again.'

    return JsonResponse(json_response)
  1. Create a new UrlMap for the get_wms_layers controller in app.py:

UrlMap(
    name='get_wms_layers',
    url='thredds-tutorial/get-wms-layers',
    controller='thredds_tutorial.controllers.get_wms_layers'
),

5. Initialize Variable and Style Select Controls

In this step you will use the new get-wms-layers endpoint to get a list of layers and their attributes (e.g. styles) to update the variable and style controls.

  1. Stub out the following variables and methods in public/js/leaflet_map.js:

/************************************************************************
*                      MODULE LEVEL / GLOBAL VARIABLES
*************************************************************************/
var public_interface,    // Object returned by the module
    m_map,               // The Leaflet Map
    m_layer_meta,        // Map of layer metadata indexed by variable
    m_curr_dataset,      // The current selected dataset
    m_curr_variable,     // The current selected variable/layer
    m_curr_style,        // The current selected style
    m_curr_wms_url;      // The current WMS url

/************************************************************************
*                    PRIVATE FUNCTION DECLARATIONS
*************************************************************************/
// Map Methods
var init_map;

// Control Methods
var init_controls, update_variable_control, update_style_control;
// Control Methods
init_controls = function() {
    console.log('Initializing controls...');
};

// Query the current WMS for available layers and add them to the variable control
update_variable_control = function() {
    console.log('Updating variable control...');
};

// Update the available style options on the style control
update_style_control = function() {
    console.log('Updating style control...');
};
/************************************************************************
*                  INITIALIZATION / CONSTRUCTOR
*************************************************************************/

// Initialization: jQuery function that gets called when
// the DOM tree finishes loading
$(function() {
    init_map();
    init_controls();
});
  1. Add on-change handlers for each control so that you can implement the logic that happens whenever a control is changed. Implement the init_controls method in public/js/leaflet_map.js:

init_controls = function() {
    // Define what happens when the dataset select input changes
    $('#dataset').on('change', function() {
        let dataset_wms = $('#dataset').val();
        let dataset_wms_parts = dataset_wms.split(';');
        m_curr_dataset = dataset_wms_parts[0];
        m_curr_wms_url = dataset_wms_parts[1];

        // Update variable control with layers provided by the new WMS
        update_variable_control();
    });

    // Define what happens when the variable select input changes
    $('#variable').on('change', function() {
        m_curr_variable = $('#variable').val();

        // Update the styles
        update_style_control();
    });

    // Define what happens when the style select input changes
    $('#style').on('change', function() {
        m_curr_style = $('#style').val();
    });

    $('#dataset').trigger('change');
};
  1. The update_variable_control method will call the new get-wms-layers endpoint and create new select options for the variable control with the returned list of layers. It will also save the layer data for use by other methods. Implement the update_variable_control method in public/js/leaflet_map.js:

update_variable_control = function() {
    // Use AJAX endpoint to get WMS layers
    $.ajax({
        url: './get-wms-layers/',
        method: 'GET',
        data: {
            'wms_url': m_curr_wms_url
        }
    }).done(function(data) {
        if (!data.success) {
            console.log('An unexpected error occurred!');
            return;
        }

        // Clear current variable select options
        $('#variable').select2().empty();

        // Save layer metadata
        m_layer_meta = data.layers;

        // Create new variable select options
        let first_option = true;
        for (var layer in data.layers) {
            if (first_option) {
                m_curr_variable = layer;
            }

            let new_option = new Option(layer, layer, first_option, first_option);
            $('#variable').append(new_option);
            first_option = false;
        }

        // Trigger a change to refresh the select box
        $('#variable').trigger('change');
    });
};
  1. The update_style_control method will used the saved layer metadata to generate style options for the style select. Implement the update_style_control method in public/js/leaflet_map.js:

update_style_control = function() {
    let first_option = true;
    for (var style in m_layer_meta[m_curr_variable].styles) {
        if (first_option) {
            m_curr_style = style;
        }

        let new_option = new Option(style, style, first_option, first_option);
        $('#style').append(new_option);
        first_option = false;
    }

    $('#style').trigger('change');
};
  1. Verify that the Variable and Style controls are updated properly when the dataset changes. Browse to http://localhost:8000/apps/thredds-tutorial in a web browser and login if necessary. Use the Dataset control to select a new dataset and verify that the Variable and Style options update accordingly. Inspect the terminal where Tethys is running to see the output from the print statement we added for debugging in Step 4.

6. Add Time-Dimension Plugin to Leaflet Map

Many of the datasets hosted on THREDDS servers have time as a dimension. In this step you will add the Time-Dimension plugin to the Leaflet map so that it can visualize data with the time dimension. The plugin adds a time slider control to the map and provides a way to load and visualize WMS layers with a time dimension.

  1. Include the Time-Dimension Leaflet plugin libraries to templates/thredds_tutorial/home.html:

{% block styles %}
  {{ block.super }}
  <link rel="stylesheet" href="https://unpkg.com/leaflet@1.6.0/dist/leaflet.css"
   integrity="sha512-xwE/Az9zrjBIphAcBb3F6JVqxf46+CDLwfLMHloNu6KEQCAWi6HcDUbeOfBIptF7tcCzusKFjFw2yuvEpDL9wQ=="
   crossorigin=""/>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet-timedimension@1.1.1/dist/leaflet.timedimension.control.min.css" />
  <link rel="stylesheet" href="{% static 'thredds_tutorial/css/leaflet_map.css' %}"/>
{% endblock %}

{% block global_scripts %}
  {{ block.super }}
  <script src="https://unpkg.com/leaflet@1.6.0/dist/leaflet.js"
   integrity="sha512-gZwIG9x3wUXg2hdXF6+rVkLF/0Vi9U8D2Ntg4Ga5I5BZpVkVxlJWbSQtXPSiUTtC0TjtGOmxa1AJPuV0CPthew=="
   crossorigin=""></script>
  <script type="text/javascript" src="https://cdn.jsdelivr.net/npm/iso8601-js-period@0.2.1/iso8601.min.js"></script>
  <script type="text/javascript" src="https://cdn.jsdelivr.net/npm/leaflet-timedimension@1.1.1/dist/leaflet.timedimension.min.js"></script>
{% endblock %}
  1. Enable the Time Dimension control when initializing the map in public/js/leaflet_map.js:

init_map = function() {
    // Create Map
    m_map = L.map('leaflet-map', {
        zoom: 3,
        center: [0, 0],
        fullscreenControl: true,
        timeDimension: true,
        timeDimensionControl: true
    });

    // Add Basemap
    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
        attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
    }).addTo(m_map);
};
  1. Verify that the Time-Dimension control is enabled. Browse to http://localhost:8000/apps/thredds-tutorial in a web browser and login if necessary. There should now be a time slider control at the bottom of the map.

7. Add Selected Dataset Layer to Map

In this step, you'll create the update_layer method that will add the THREDDS dataset WMS layer to the Leaflet map.

  1. Declare the following variables in public/js/leaflet_map.js:

/************************************************************************
*                      MODULE LEVEL / GLOBAL VARIABLES
*************************************************************************/
var public_interface,    // Object returned by the module
    m_map,               // The Leaflet Map
    m_layer,             // The layer
    m_td_layer,          // The Time-Dimension layer
    m_layer_meta,        // Map of layer metadata indexed by variable
    m_curr_dataset,      // The current selected dataset
    m_curr_variable,     // The current selected variable/layer
    m_curr_style,        // The current selected style
    m_curr_wms_url;      // The current WMS url

/************************************************************************
*                    PRIVATE FUNCTION DECLARATIONS
*************************************************************************/
// Map Methods
var init_map, update_layer;
  1. Implement the update_layer method in public/js/leaflet_map.js:

update_layer = function() {
    if (m_td_layer) {
        m_map.removeLayer(m_td_layer);
    }

    // Layer
    m_layer = L.tileLayer.wms(m_curr_wms_url, {
        layers: m_curr_variable,
        format: 'image/png',
        transparent: true,
        colorscalerange: '250,350',  // Hard-coded color scale range won't work for all layers
        abovemaxcolor: "extend",
        belowmincolor: "extend",
        numcolorbands: 100,
        styles: m_curr_style
    });

    // Wrap WMS layer in Time Dimension Layer
    m_td_layer = L.timeDimension.layer.wms(m_layer, {
        updateTimeDimension: true
    });

    // Add Time-Dimension-Wrapped WMS layer to the Map
    m_td_layer.addTo(m_map);
};
  1. Call the update_layer method when the style changes. Update the on-change handler for the style control near the end of the init_controls method:

// Define what happens when the style select input changes
$('#style').on('change', function() {
    m_curr_style = $('#style').val();

    // Update the layer with the new styles
    update_layer();
});
  1. Use the bounding box retrieved from the WMS service to automatically frame the selected layer on the map. Update the on-change handler for the variable control defined in the init_controls method:

$('#variable').on('change', function() {
    m_curr_variable = $('#variable').val();

    // Update the styles
    update_style_control();

    // Zoom to the bounding box of the new layer
    let bbox = m_layer_meta[m_curr_variable].bbox;
    m_map.fitBounds(bbox);
});
  1. Verify that the layers show up on the map. Browse to http://localhost:8000/apps/thredds-tutorial in a web browser and login if necessary. Select the "Best GFS Half Degree Forecast Time Series" dataset using the Dataset control to test a time-varying layer. Press the Play button on the Time-Dimension control to animate the layer.

8. Implement Legend for Layers

The THREDDS implementation of the WMS standard includes support for the GetLayerGraphic request. In this step you'll use this request to generate a legend image for the layer and style selected.

  1. Add an HTML element for the legend just under the dataset select controls to templates/thredds_tutorial/home.html:

{% block app_navigation_items %}
  <li class="title">Query</li>
  {% gizmo dataset_select %}
  {% gizmo variable_select %}
  {% gizmo style_select %}
  <div id="legend">
  </div>
{% endblock %}
  1. Declare the following variables in public/js/leaflet_map.js:

/************************************************************************
*                    PRIVATE FUNCTION DECLARATIONS
*************************************************************************/
// Map Methods
var init_map, update_layer;

// Control Methods
var init_controls, update_variable_control, update_style_control;

// Legend Methods
var update_legend, clear_legend;
  1. To display the legend image, simply set the src attribute of an image element to the GetLegendGraphic request URL. Implement the update_legend method in public/js/leaflet_map.js:

// Legend Methods
update_legend = function() {
    let legend = m_layer_meta[m_curr_variable].styles[m_curr_style].legend;
    $('#legend').html('<li class="title">Legend<h1></li><img src="' + legend + '">');
};
  1. Implement the clear_legend method in public/js/leaflet_map.js:

clear_legend = function() {
    $('#legend').html('');
};
  1. Update the update_layer method to call the clear_legend and update_legend methods before and after updating the layer, respectively:

update_layer = function() {
    if (m_td_layer) {
        m_map.removeLayer(m_td_layer);
    }

    // Clear the legend
    clear_legend();

    // Layer
    m_layer = L.tileLayer.wms(m_curr_wms_url, {
        layers: m_curr_variable,
        format: 'image/png',
        transparent: true,
        colorscalerange: '250,350',  // Hard-coded color scale range won't work for all layers
        abovemaxcolor: "extend",
        belowmincolor: "extend",
        numcolorbands: 100,
        styles: m_curr_style
    });

    // Wrap WMS layer in Time Dimension Layer
    m_td_layer = L.timeDimension.layer.wms(m_layer, {
        updateTimeDimension: true
    });

    // Add Time-Dimension-Wrapped WMS layer to the Map
    m_td_layer.addTo(m_map);

    // Update the legend graphic
    update_legend();
};
  1. Verify that the legend has been added to the app. Browse to http://localhost:8000/apps/thredds-tutorial in a web browser and login if necessary. The legend should appear under the Query controls in the navigation window on the left. Change the style and verify that the legend update accordingly.

9. Implement a Map Loading Indicator

Depending on the speed of the THREDDS server and the user's internet connection, loading the layers on the map may take some time. In this step you'll add a loading indicator so that the user knows when the app is working on loading layers.

  1. Download this animated map loading image or find one that you like and save it to the public/images directory.

  2. Create a new stylesheet called public/css/loader.css with styles for the loader elements:

#loader {
    display: none;
    position: absolute;
    top: calc(50vh - 185px);
    left: calc(50vw - 186px);
}

#loader img {
    border-radius: 10%;
    box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
}

#loader.show {
    display: block;
}
  1. Include the new public/css/loader.css and add the image to the after_app_content block of the templates/thredds_tutorial/home.html template:

{% block styles %}
  {{ block.super }}
  <link rel="stylesheet" href="https://unpkg.com/leaflet@1.6.0/dist/leaflet.css"
   integrity="sha512-xwE/Az9zrjBIphAcBb3F6JVqxf46+CDLwfLMHloNu6KEQCAWi6HcDUbeOfBIptF7tcCzusKFjFw2yuvEpDL9wQ=="
   crossorigin=""/>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet-timedimension@1.1.1/dist/leaflet.timedimension.control.min.css" />
  <link rel="stylesheet" href="{% static 'thredds_tutorial/css/leaflet_map.css' %}"/>
  <link rel="stylesheet" href="{% static 'thredds_tutorial/css/loader.css' %}" />
{% endblock %}
{% block after_app_content %}
  <div id="loader">
    <img src="{% static 'thredds_tutorial/images/map-loader.gif' %}">
  </div>
{% endblock %}
  1. Declare the map loader methods in public/js/leaflet_map.js:

/************************************************************************
*                    PRIVATE FUNCTION DECLARATIONS
*************************************************************************/
// Map Methods
var init_map, update_layer;

// Control Methods
var init_controls, update_variable_control, update_style_control;

// Legend Methods
var update_legend, clear_legend;

// Loader Methods
var show_loader, hide_loader;
  1. Implement the show_loader and hide_loader methods in public/js/leaflet/map.js:

// Loader Methods
show_loader = function() {
    $('#loader').addClass('show');
};

hide_loader = function() {
    $('#loader').removeClass('show');
};
  1. Bind the show_loader and hide_loader methods to the tile loading events of the layer:

update_layer = function() {
    if (m_td_layer) {
        m_map.removeLayer(m_td_layer);
    }

    // Clear the legend
    clear_legend();

    // Layer
    m_layer = L.tileLayer.wms(m_curr_wms_url, {
        layers: m_curr_variable,
        format: 'image/png',
        transparent: true,
        colorscalerange: '250,350',  // Hard-coded color scale range won't work for all layers
        abovemaxcolor: "extend",
        belowmincolor: "extend",
        numcolorbands: 100,
        styles: m_curr_style
    });

    // Wrap WMS layer in Time Dimension Layer
    m_td_layer = L.timeDimension.layer.wms(m_layer, {
        updateTimeDimension: true
    });

    // Add events for loading
    m_layer.on('loading', function() {
        show_loader();
    });

    m_layer.on('load', function() {
        hide_loader();
    });

    // Add Time-Dimension-Wrapped WMS layer to the Map
    m_td_layer.addTo(m_map);

    // Update the legend graphic
    update_legend();
};

Note

The loading event is called whenever tile layers start loading and the load event is called when the visible tiles of a tile layer have finished loading. See: TileLayer.WMS reference.

  1. Also show the map loader when the variable control is updating (the AJAX call to get the WMS layers could take some time to run):

update_variable_control = function() {
    // Show loader
    show_loader();

    // Use AJAX endpoint to get WMS layers
    $.ajax({
        url: './get-wms-layers/',
        method: 'GET',
        data: {
            'wms_url': m_curr_wms_url
        }
    }).done(function(data) {
        if (!data.success) {
            console.log('An unexpected error occurred!');
            return;
        }

        // Clear current variable select options
        $('#variable').select2().empty();

        // Save layer metadata
        m_layer_meta = data.layers;

        // Create new variable select options
        let first_option = true;
        for (var layer in data.layers) {
            if (first_option) {
                m_curr_variable = layer;
            }

            let new_option = new Option(layer, layer, first_option, first_option);
            $('#variable').append(new_option);
            first_option = false;
        }

        // Trigger a change to refresh the select box
        $('#variable').trigger('change');

        // Hide the loader
        hide_loader();
    });
};

10. Clean Up

During development it is common to use print statements. Rather than delete these when you are done, turn them into log statements so that you can use them for debugging in the future.

  1. Use the Python logging module to get a logger for this module:

import logging

log = logging.getLogger(__name__)
  1. Replace print and pprint calls with log statements in controllers.py:

@login_required()
def home(request):
    """
    Controller for the app home page.
    """
    catalog = app.get_spatial_dataset_service(app.THREDDS_SERVICE_NAME, as_engine=True)

    # Retrieve dataset options from the THREDDS service
    log.info('Retrieving Datasets...')
    datasets = parse_datasets(catalog)
    initial_dataset_option = datasets[0]
    log.debug(datasets)
    log.debug(initial_dataset_option)

    ...
@login_required()
def get_wms_layers(request):
    json_response = {'success': False}

    if request.method != 'GET':
        return HttpResponseNotAllowed(['GET'])

    try:
        wms_url = request.GET.get('wms_url', None)

        log.info(f'Retrieving layers for: {wms_url}')

        ...
  1. Replace print and pprint calls with log statements in thredds_methods.py:

import logging

log = logging.getLogger(__name__)
def get_layers_for_wms(wms_url):
    """
    Retrieve metadata from a WMS service including layers, available styles, and the bounding box.

    Args:
        wms_url(str): URL to the WMS service endpoint.

    Returns:
        dict<layer_name:dict<styles,bbox>>: A dictionary with a key for each WMS layer available and a dictionary value containing metadata about the layer.
    """
    wms = WebMapService(wms_url)
    layers = wms.contents
    log.debug('WMS Contents:')
    log.debug(layers)

    layers_dict = dict()
    for layer_name, layer in layers.items():
        layer_styles = layer.styles
        layer_bbox = layer.boundingBoxWGS84
        leaflet_bbox = [[layer_bbox[1], layer_bbox[0]], [layer_bbox[3], layer_bbox[2]]]
        layers_dict.update({
            layer_name: {
                'styles': layer_styles,
                'bbox': leaflet_bbox
            }
        })

    log.debug('Layers Dict:')
    log.debug(layers_dict)
    return layers_dict

Tip

Logging excessively can impact the performance of your app. Use info, error, and warning to log minimal, summary information that is useful for monitoring normal operation of the app. Use debug to log more detailed information to help you assess bugs or other issues with your app without needing to modify the code. In production, the Tethys Portal can be configured to log at different levels of detail using these classifications. See: Python Logging HOWTO and Tethys Portal Configuration.

11. Test and Verify

Browse to http://localhost:8000/apps/thredds-tutorial in a web browser and login if necessary. Verify the following:

  1. A Leaflet map should be loaded on the page with one of the datasets visualized

  2. There should be 3 controls in the navigation menu on the left: Dataset, Variable, and Style

  3. There should be a legend for the current layer under the control in the navigation menu.

  4. The map should feature an animation slider. If the dataset selected has time varying data, the slider should display a time step. Otherwise it will say "Time not available".

  5. Select the "Best GFS Half Degree Forecast Time Series" dataset using the Dataset control to test a time-varying layer. Press the Play button on the Time-Dimension control to animate the layer.

12. Solution

This concludes the New App Project portion of the THREDDS Tutorial. You can view the solution on GitHub at https://github.com/tethysplatform/tethysapp-thredds_tutorial/tree/thredds-service-solution-3.0 or clone it as follows:

git clone https://github.com/tethysplatform/tethysapp-thredds_tutorial.git
cd tethysapp-thredds_tutorial
git checkout -b visualize-leaflet-solution visualize-leaflet-solution-3.0