Using D3 and Leaflet to visualise how the Lousiana coastline has changed.

This project is inspired by the USGS ‘Coastal Change Hazard portal’, but using Open Source technologies Leaflet and D3. This was to allow the USGS collected data to be spatially analysed using popular mapping and graphical visualisation software, without use of a traditional GIS platform such as ArcGIS.

Year: 1832

Step One: Loading in the necessary assets and setting the project up.

The first step for a project like this is to load in all the elements that are needed. They can be loaded locally, or using an ‘unpkg’ or similar link.
The head of the HTML document should look something like this, with the appropriate scripts loaded in like so:

<head>
    <meta charset="UTF-8">
    <title>Shoreline changes</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">

    <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" rel="stylesheet">

    <script src="https://code.jquery.com/jquery-3.4.1.js"></script>

    <script src="https://code.jquery.com/ui/1.12.1/jquery-ui.min.js"></script>

    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.3.1/leaflet.css"/>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.3.1/leaflet.js"></script>

    <script src="https://d3js.org/d3.v4.min.js"></script>

     <script src="https://cdn.jsdelivr.net/npm/leaflet-providers@1.9.0/leaflet-providers.min.js"></script>

    <script src="https://unpkg.com/d3-simple-slider"></script>
    <script src="https://d3js.org/d3-format.v1.min.js"></script>

    <link rel="stylesheet" href="../shoreline-changes/shoreline-styles.css">
</head>

Note the mobile tag below the title. The core modules needed for this are: Leaflet, D3, Jquery and D3 simple slider. The others are optional such as Leaflet providers which is used for the basemaps (The ESRI satellite imagery is used). Bootstrap is also optional but it makes formating the map slider and description much easier. The last file to be loaded is the CSS, which sets the maps dimensions and is used to style the slider and is as follows:

html, body{
    height: 100%;
    margin: auto;
}

#map {
    width: 100%;
    height: 500px;
}

The last part of setting the project up is to create an empty Javascript file, link it into the HTML and set up the body HTML with the map, slider and description as follows:

<body>
<div class="container-fluid">
    <div class="row text-center">
        <div class="col-md-12">
            <p id="map"></p>
            <p id="year">Year: <span id="value">1832</span></p>
            <div class="row">
                <div class="col-md-6">
                    <div id="slider"></div>
                </div>
                <div class="col-md-6">
                    <b>Shoreline changes of off the Louisiana Coast</b><br>
                    This dataset includes shorelines from 169 years ranging from 1832 to 2001 for the Louisiana coastal region from the Chandeleur Islands to Raccoon Point, on Isles Dernieres at the mouth of Caillou Bay. Data sources: lidar.
                </div>
            </div>
        </div>
    </div>
</div>
<script type="text/javascript" src="../shoreline-changes/shoreline-changes.js"></script>
</body>

Step 2: Initalising the Map

The first stage is to initalise the leaflet map and add the basemaps. (Using the ESRI basemap a label layer is needed, as the ESRI layer does not have labels). I highly reccomend using leaflet basemap providers linked above as it makes adding the tile layers trivial.

var map;

map = L.map('map', {center: [29.54479, -90.51361], zoom: 9});

L.tileLayer.provider('Esri.WorldImagery').addTo(map);
L.tileLayer.provider('CartoDB.PositronOnlyLabels').addTo(map);

If you are not using Leaflet Basemap Providers, the typical way of adding the tileLayer is as follows:

L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
    maxZoom: 13,
}).addTo(map);

var CartoDB_PositronOnlyLabels = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_only_labels/{z}/{x}/{y}{r}.png', {
    attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
    subdomains: 'abcd',
    maxZoom: 19
}).addTo(map);

As you can see, leaflet providers turns 8 lines of code into 2 and the library itself is only 17.35kB.

Step 3: Initalising D3.

This step loads in the geojson file of the Lousianna shoreline changes, which can be downloaded from the coastal hazard website or from this gist here. I slightly edited the data, so the ‘Year_’ property was just ‘year’. A simple find and replace can achieve this.

The first D3 operation used is to create the SVG and add it to the map, using the leaflet overlay pane. This is slightly unorthodox in traditional D3 terms, as it means a seperate SVG element is not needed in the HTML:

var svg = d3.select(map.getPanes().overlayPane).append("svg"),
    g = svg.append("g").attr("class", "leaflet-zoom-hide");

Note the line 'g = svg.append', where the g element is the SVG group. The zoom hide is optional.

Step 4: Loading the GeoJSON data and intergrating it with leaflet.

This step took significant trial and error, so I am going to include the whole code block, then break it down and explain what each part does.

d3.json("../shoreline-changes/data/LAshorelinesGEOJSON.geojson", function(json) {
    // Use Leaflet to implement a D3 geometric transformation.
    function projectPoint(x, y) {
    // Returns the map layer point that corresponds to the given geographical coordinates
        var point = map.latLngToLayerPoint(new L.LatLng(y, x));
        this.stream.point(point.x, point.y);
    }

    var transform = d3.geoTransform({point: projectPoint}),
        path = d3.geoPath().projection(transform);

    var feature = g.selectAll("path")
        .data(json.features)
        .enter().append("path")
        .attr("d", path)
        .style("fill", "none")
        .style("stroke-width", "1")
        .attr("stroke", function(json)
        {if (json.properties.year == "1853")
        {return "blue"}
        else if(json.properties.year == "1855")
        {return "yellow"}
        else if(json.properties.year == "1869")
        {return "red"}
        else if(json.properties.year == "1877")
        {return "green"}
        else if(json.properties.year == "1883")
        {return "black"}
        else if(json.properties.year == "1884")
        {return "grey"}
        else if(json.properties.year == "1887")
        {return "orange"}
        else if(json.properties.year == "1922")
        {return "teal"}
        else if(json.properties.year == "1932")
        {return "#0bfdff"}
        else if(json.properties.year == "1973")
        {return "#ff06f9"}
        else if(json.properties.year == "1978")
        {return "#ffc106"}
        else if(json.properties.year == "1996")
        {return "#ffc700"}
        else (json.properties.year == "2001")
        {return "#ff000e"}
        });

    map.on("zoomend", reset);
    reset();

    // Reposition the SVG to cover the features.
    function reset() {
            bounds = path.bounds(json);
            var topLeft = bounds[0],
                bottomRight = bounds[1];

        svg .attr("width", bottomRight[0] - topLeft[0])
            .attr("height", bottomRight[1] - topLeft[1])
            .style("left", topLeft[0] + "px")
            .style("top", topLeft[1] + "px");

        g   .attr("transform", "translate(" + -topLeft[0] + "," + -topLeft[1] + ")");

        feature.attr("d", path);
        console.log(json);
    }
});

The first stage of this is to use d3.json to load the data, and using an anonymous function to add the json to a argument of this function:

d3.json("../shoreline-changes/data/LAshorelinesGEOJSON.geojson", function(json) {}

Next, using a helpful tutorial from Mike Bostock, on how to intergrate D3 with Leaflet the following steps are taken:

As D3 and Leaflet use different methods for projecting points and shapes a custom geometric transformation is needed. What a transform does is converts one input geometry to a different output geometry, but retains the spatial attributes of the geometry. Using the d3.geoTransform function a simple function that projects individual points:

// Use Leaflet to implement a D3 geometric transformation.
function projectPoint(x, y) {
        // Returns the map layer point that corresponds to the given geographical coordinates
        var point = map.latLngToLayerPoint(new L.LatLng(y, x));
        this.stream.point(point.x, point.y);
}

Next a d3.geoPath is used to convert GeoJSON to SVG:

var transform = d3.geoTransform({point: projectPoint}),
        path = d3.geoPath().projection(transform); // the path element is a vital part of the SVG standard.

Next a feature variable is created and the data is joined to it (The json.features is the argument from the call back in the d3.json function). The enter function breaks down from the data, and the append function adds the data to the path created in the step above:

var feature = g.selectAll("path")
        .data(json.features)
        .enter().append("path")
        .attr("d", path)
        .style("fill", "none")
        .style("stroke-width", "1")

The next step is to style the shoreline colour by year. This is done by using the ‘attr’ selector in d3. An anonymous function is used, which again takes the json data as the argument. A combinaton of else and else if statements are used, along with json.properties.year which allows a selection of the year using an ‘abstract comparator’ ==. The colours can be either css colour values or hex codes inside a return statement:

    var feature = g.selectAll("path")
        .data(json.features)
        .enter().append("path")
        .attr("d", path)
        .style("fill", "none")
        .style("stroke-width", "1")
        .attr("stroke", function(json)
        {if (json.properties.year == "1853")
        {return "blue"}
        else if(json.properties.year == "1855")
        {return "yellow"}
        else if(json.properties.year == "1869")
        {return "red"}
        else if(json.properties.year == "1877")
        {return "green"}
        else if(json.properties.year == "1883")
        {return "black"}
        else if(json.properties.year == "1884")
        {return "grey"}
        else if(json.properties.year == "1887")
        {return "orange"}
        else if(json.properties.year == "1922")
        {return "teal"}
        else if(json.properties.year == "1932")
        {return "#0bfdff"}
        else if(json.properties.year == "1973")
        {return "#ff06f9"}
        else if(json.properties.year == "1978")
        {return "#ffc106"}
        else if(json.properties.year == "1996")
        {return "#ffc700"}
        else (json.properties.year == "2001")
        {return "#ff000e"}
        });

The final part of this code block is the function that updates the SVG when the leaflet map is moved or zoomed. A function called reset was created, and called when the map zoomend event was fired:

map.on("zoomend", reset);
reset();

The reset function first gets the bounds of the json using bounds = path.bounds(json) and sets the top left and bottom right positions using the [0], [1] positions:

bounds = path.bounds(json);
var topLeft = bounds[0],
bottomRight = bounds[1];

Finally, the SVG is resized using the bounds calculated above:

svg .attr("width", bottomRight[0] - topLeft[0])
            .attr("height", bottomRight[1] - topLeft[1])
            .style("left", topLeft[0] + "px")
            .style("top", topLeft[1] + "px");

        g   .attr("transform", "translate(" + -topLeft[0] + "," + -topLeft[1] + ")");

        feature.attr("d", path);

The full code block looks like this:

    // Reposition the SVG to cover the features.
    function reset() {
            bounds = path.bounds(json);
            var topLeft = bounds[0],
                bottomRight = bounds[1];

        svg .attr("width", bottomRight[0] - topLeft[0])
            .attr("height", bottomRight[1] - topLeft[1])
            .style("left", topLeft[0] + "px")
            .style("top", topLeft[1] + "px");

        g   .attr("transform", "translate(" + -topLeft[0] + "," + -topLeft[1] + ")");

    feature.attr("d", path);
}

Step 5: The slider

The final step of this tutorial is creating the slider and using it to edit the visibility of the shoreline layer using the year. For this a simple plugin called d3 simple slider is used. This was used as why reinvent the wheel, and it was perfect for this project.

The slider was initalised, with a minimum value of 1832 and a maximum value of 2001, with a step of 1 (Despite the date not being every year this was the easiest implementation). The tick format was formatted to thousands using d3.format. A simple arrow function is used on the 'onchange' state of the slider which basically says: if the value of the year is less than the value of the slider, the opacity of the layer is set to invisible, and if this is the opposite, the opacity is set to visible.

var slider = d3
    .sliderHorizontal()
    .min(1832)
    .max(2001)
    .step(1)
    .width(400)
    .tickFormat(d3.format('k'))
    .displayValue(false)
    .on('onchange', val => {
        d3.select('#value').text(val);
        d3.selectAll('svg path').each(function(d){
            if (d.properties.year > val){
                this.style.opacity = 1;
            } else {
                this.style.opacity = 0;
            }
        })
    });

d3.select('#slider')
    .append('svg')
    .attr('width', 500)
    .attr('height', 100)
    .append('g')
    .attr('transform', 'translate(30,30)')
    .call(slider);

Finally, set the CSS for the slider, and the year box and make the SVG position relative:

svg {
    position: relative;
}

#year {
    position: absolute;
    top: 82px;
    z-index: 99999999;
    background-color: cadetblue;
    display: inline-block;
    padding: 15px;
    left: 15px;
    color: white;
    font-weight: bold;
}

Thanks for reading, I hope it was easy to follow! Any questions email me at lucasmartinmapping@gmail.com