JavaScript Drag and Drop to Sort Locations and Render Routes
08/23/2020
This is a write-up on the subject feature of the Trip Planner app that I recently built for the Flatiron School’s software engineering program. With this feature, users can drag and drop saved places in their daily planners to determine the order of visiting each place. Based on this order, the app displays daily routing and directions on click.
A full implementation of the feature involves working with:
- HTML Drag and Drop API; and
- Google Maps Platform, especially the places library and the directions service
Drag and Drop Elements with Sorting
Draggable Elements
First, identify draggable items, which will have a draggable attribute set as true. They will also listen for the dragstart and dragend events. In my example, the draggable items are dynamically added to a list when user clicks on the save button, like so:
const addPlaceToPlanner = () => {// get place_idconst placeId = document.querySelector(".place-details").idconst placeItem = document.createElement("div")// set place_id on item for later google map directions queriesplaceItem.setAttribute("data-place-id", placeId)placeItem.setAttribute("draggable", true)placeItem.innerHTML = `` // omitted HTMLdocument.querySelector(".place-bucket").appendChild(placeItem)}
The following points are worth noting:
- Make sure to preserve the
pace_id(a unique id assigned by Google), which is important for route rendering; more details on this later - Duplication of draggable elements can be achieved through the
cloneNode()method as follows:
const duplicateListItem = e => {const itemNode = e.target.parentNode.parentNodeconst clone = itemNode.cloneNode(true)itemNode.after(clone)}
- In case duplication is needed, place_id should be saved with the data instead of id attribute; this is because
cloneNode()also clones the element’s id, which needs to be unique on the DOM
Assign dragstart and dragend Event Listeners
In my case, the dragstart and dragend event listeners are delegated from the entire panel to individual list items. It is necessary for the duplication feature. The cloneNode() method does not clone event listeners from the original elements. If duplication is not needed, you can add the event listeners on the item as you append it to the parent element.
document.querySelector(".planner-content").addEventListener("dragstart", e => {if (e.target.closest(".list-item")) {e.target.closest(".list-item").classList.add("dragging")}})document.querySelector(".planner-content").addEventListener("dragend", e => {if (e.target.closest(".list-item")) {e.target.closest(".list-item").classList.remove("dragging")}})
The above code allows for the targeting of the single element that is being dragged on the DOM through the class name of “dragging”, which facilitates style changes and item sorting in the receiving container when drag ends.
Assign dragover Event Listeners to Containers
Identify containers that will receive draggable elements to assign dragover event listeners, like so:
dayBox.addEventListener("dragover", e => {// if you don't use arrow function, you can refer to e.currentTarget by 'this'const container = e.currentTarget// there is only one single element with this classNameconst item = document.querySelector(".dragging")// e.clientY returns the vertical coordinate within client area where the event occured// the dragover event continuously occursconst afterElement = getDragAfterElement(container, e.clientY)if (afterElement) {container.insertBefore(item, afterElement)} else {container.appendChild(item)}// the default handling of the dragover event is not to allow a drope.preventDefault()})const getDragAfterElement = (container, y) => {const draggableElms = [...container.querySelectorAll(".list-item:not(.dragging)"),]// think of arguments in the reduce as:// closest element which we insert the dragging element before// current child of containerreturn draggableElms.reduce((closest, child) => {// the size of the element and its position relative to the viewportconst rect = child.getBoundingClientRect()// (rect.top + rect.height/2) returns the y of the container's child element's middle pointconst offset = y - (rect.top + rect.height / 2)// if the dragging element is immediately above the child's middle pointif (offset < 0 && offset > closest.offset) {return { offset: offset, element: child }} else {return closest}},// the initial value in the reduce function is negative infinity{ offset: Number.NEGATIVE_INFINITY }).element}
I provide comments in the above code to explain, especially, the getDragAfterElement() function, which involves comparing the Y coordinates of the dragging element to those of the existing elements in the receiving container. The reduce() function within getDragAfterElement() returns the element whose middle point the dragging element is immediately above. If the code is unclear, please test with console.logging the y and offset values.
This concludes the actions for drag and drop with sorting. The below section explains how to work with the Google Maps Platform to render routes for a given day’s locations.
Route and Directions Rendering
Set up Google Maps API
Sign up for the Google Maps Platform, which provides a trial period. Please refer to Google’s documentation on how to get an API key, enable services, and restrict API call access. Make sure to enable the following APIs:
- Maps JavaScript API
- Places API
- Directions API
After everything is squared away with Google, include the following script tag in your index.html’s head.
<script defer src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY&libraries=places"></script>
If you include callback=initMap in the script tag, make sure you have an initMap() function available globally and your index.js tag is before the Google maps API script tag.
In my example, I call initMap() after the user enters a location so I can pass in the latitude and longitude, like so:
// set these variables globally or in the modulelet map;let service;let directionsService;let directionsRenderer;const markers = {}; // only if there is a need to display markersconst initMap = center => {// a div with #map is required in the HTMLmap = new google.maps.Map(document.querySelector('#map'), {// pass a center like so: { lat: ... , lgn: ... }center: center,zoom: 13,// this parameter is not requiredstyles: GMStyles.mapStyles});}
Note:
- Declare the
map,service,directionsServiceanddirectionsRendererso that consecutive routing can be made with the previous one cleared; this may not be the best practice and I will make updates after I further study managing state through React - Only the center and zoom parameters are required to initialize the map; if styles are needed, I recommend generating a JSON style with the style wizard
Get place_ids for Route Mapping and Set Up directionsPanel for Displaying Directions
With the place items’ order already sorted through drag and drop on the DOM and the place_id saved with the elements’ data attribute, we can get an array of place_ids like so:
const getPlaceIds = e => {// title element is before the list of place itemsconst titleElm = e.target.closest('.title');const itemElms = (titleElm.nextElementSibling.children ? [...titleElm.nextElementSibling.children] : null);// no action if there are less than 2 placesif (!itemElms || itemElms.length < 2) return null;return itemElms.map(item => item.dataset.placeId);}
Before we can display the directions, we need to ensure that there is a div with the id directionsPanel on the DOM. In my case, I needed to dynamically replace the place overview panel or the place details panel with the directions panel, so I created a function like so:
function createDirectionsPanel() {const directionsPanel = document.createElement('div');// google requires a div with #directionsPaneldirectionsPanel.id = 'directionsPanel';directionsPanel.innerHTML = `...` // omitted HTMLreturn directionsPanel;}
Render Routes and Directions
With all the setup and preparations done, we can finally render the routes and directions! Below is the function for this step:
const renderRoute = placeIds => {removeDirectionsRenderer(); // remove previous directionsdirectionsService = new google.maps.DirectionsService(); // resets the global variabledirectionsRenderer = new google.maps.DirectionsRenderer();// construct an array of waypoint objectsconst stopovers = placeIds.slice(1, placeIds.length - 1).map(id => {return {stopover: true, location: {placeId: id}} // passing the place_id is an easy way to query directions});const request = {origin: {placeId: placeIds[0]},destination: {placeId: placeIds[placeIds.length - 1]},waypoints: stopovers,travelMode: google.maps.TravelMode.WALKING, // pass the desired travel modeunitSystem: google.maps.UnitSystem.IMPERIAL // pass the desired unit system};directionsService.route(request, function(result, status) {if (status == 'OK') {directionsRenderer.setMap(map); // map accessible globallyclearMarkers(); // only necessary if there are existing markers on the map// clear all elements that may be displaying where the directions panel should be rendereddocument.querySelector('.place-overview').style.display = 'none';removeItem(document.querySelector('.place-details'));removeItem(document.querySelector('#directionsPanel'));// set up directions panel for rendering directionsconst directionsPanel = createDirectionsPanel();directionsRenderer.setPanel(directionsPanel);directionsRenderer.setDirections(result);} else {alert(status);}});}// this is necessary so there are not multiple routes displaying on the mapconst removeDirectionsRenderer = () => {if (directionsRenderer != null) {directionsRenderer.setMap(null);directionsRenderer = null;}}const removeItem = item => {if(item) item.remove();}
Some of the code in the above example use Google Map API’s classes and methods. I include links to the API reference with brief and easy-to-understand explanations below:
- google.maps.DirectionsService class
- route(request, callback)
- setMap(map)
- setPanel(panel)
- setDirections(directions)
Conclusion
This is the entire process for implementing drag and drop to sort locations and render routes. The Google Maps Platform includes several big APIs worth exploring. With their well-written guides and references, we can easily practice our problem-solving skills, and as a bonus, create cool things!