Managing the Many Moving (and Zooming) Parts of ZoLa
By: Taylor Mcginnis
Tags: JavaScript, Mapbox, Ember, Engineering, Web Mapping

In order to keep track of the varying functionalities in all of our different layers, we created an Airtable that outlines the details of each feature, including whether a feature is routable, has a tooltip in the legend, or will trigger a change in the zoom level when clicked. Organizing this Airtable required a group effort from the team as we sat down together and talked through each feature one by one. Taking the time to keep track of these many intended behaviors helped us to better organize and understand our code going forward, especially in regards to catching bugs, adding new features, and writing tests.

Our ZoLa behavior Airtable

Multiple Behaviors on One Route

Managing these functionalities in the app can prove more complicated when a single layer has multiple behaviors. For example, users can access a specific zoning district (e.g. R6B) one of two ways. They can search for the layer in the search bar or they can click on the layer in the map. Both searching and clicking on the zoning districts layer will cause a transition to the zoning-district route, which will be reflected in the URL.

The zoning-district route reflected in the URL with path R6B

Everything after the ? represents our query parameters, which change when a user toggles a layer on and off. We define our query parameters in our application.js controller with help from the addon, Ember Parachute. In this case, the layers, subway and zoning-districts , are toggled ON. These query parameters make it easy to reserve the same settings when copying and pasting URLs into a new browser.

Query params show that layer-groups “subway” and “zoning-districts” are toggled ON

In order to determine what happens on a route transition, we set up functions and tasks in our route, like this task below which changes the map’s zoom level and coordinates to fit the bounds of a feature layer.

waitToFitBounds: task(function* (taskInstance) {

const model = yield taskInstance; this.set('mainMap.selected', model);

this.get('mainMap.setBounds').perform();

}).restartable().cancelOn('deactivate'),

Our intended behavior for this route was this: When a user searches for R6B in the search bar, the map will fit bounds to all of the features associated with that zoning id, which means the map will encompass parts of the Bronx, Manhattan, Brooklyn, and Queens. When a user is navigating over the map and happens to click on the R6B layer, the map should not move at all. We decided that fitting the bounds when clicking on the map would be too disruptive for a user trying to remain in one location. As mentioned earlier, both events trigger a transition to a new route, but when this transition happens we want the app to do two different things depending on how the user landed on the route.

What the user should see when they search R6B, the map should “fit bounds” to the entire layer

In order to manage this, we created a new boolean query parameter called search in the controller with the default value of false . This new query parameter should appear in our URL when an action triggers a value change on search . Through using ember’s transitionTo() and transitionToRoute() methods, we get to determine the route, model, and query params of a route change.

When a user clicks on a layer, the search query param will be set to false :

if (zonedist) {

this.transitionTo('zoning-district', zonedist, {

queryParams: { search: false } });

}

When a user searches for a layer, the search query param will be set to true :

if (type === 'zoning-district') {

this.set('searchTerms', result.label);

this.transitionToRoute('zoning-district', result.label, { queryParams: { search: true } });

}

We want this behavior to be reflected on more layers than just zoning districts, we also want to see it on commercial overlays, special purpose districts, special purpose subdistricts, and zoning map amendments. Instead of duplicating the same functionality in each of these five routes, we instead store it in a Mixin. This way, we can define behavior on one Mixin, and then inject and extend it anywhere in our code. By moving the waitToFitBounds task to this new Mixin, and then setting conditions based on search in an afterModel() hook, we now have a way to trigger different actions based on how a user lands on a route.

afterModel({ taskInstance }, transition) {

if (transition.queryParams.search === 'true') {

this.waitToFitBounds.perform(taskInstance);

}

},

Now when a user searches for R6B, the map will zoom to fit the bounds of all zoning boundaries that are designated R6B, and they will also see the query params for search reflected in the URL.

Search query params

Deciding on which layers get their own routes and which layers will be reflected as query parameters are up to the developer. In our case, we decided that all layers that opened up an information tab when clicked or searched deserved their own route, but this may change in the future as we don’t want to overload the application with too many routes. Our efforts to keep a formal account of the various intended behaviors in our app will help us to implement more organized refactors in the future, and better understand how to modify the app to fit user needs.