Managing the Many Moving (and Zooming) Parts of ZoLa
Our Zoning and Land Use Application (ZoLa) at NYC Planning Labs incorporates over thirty different map layers, ranging from zoning districts to flood zones to 3D buildings. Each layer has its own style and functionality, which determine how a user views and interacts with the application. Users can toggle layers on and off, search for layers in the search bar, and click on layers on the map. For more relevant features, like zoning districts, commercial overlays, and zoning map amendments, clicking on or searching for a layer will open up a pane with details about that feature and links to more information.
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.
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.
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.
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.
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.
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.