loading...
Cover image for RockOn pt 3: Handling Hierarchical Areas

RockOn pt 3: Handling Hierarchical Areas

dianakw8591 profile image dianakw8591 Updated on ・6 min read

This is the third post in my series about building RockOn. Previously I discussed the implementation of route name search, and further background is here.

Last week I discussed my process for implementing a search by route name in RockOn and why I felt that it was a key component of the user experience. Another common scenario I wanted to account for was the case when multiple routes are completed at the same crag or area. Imagine going to your local rock outcropping and climbing five routes there. Would you rather have to type in each route name to make your entries, or search for "local rock outcropping" and then quickly select the routes you did from the list that was returned?

The User Experience

The most immediate challenge when thinking about areas is that all of them are different sizes! An area like Yosemite has 2,000+ routes nested under it, too many for a dropdown. I considered a series of nested searches, where a search for a high-up parent area would then provide a dropdown of all its children areas, until eventually reaching the specific crag containing only routes. This would have been complicated to build and likely a poor user experience if many levels of areas had to be navigated. Beyond that, the lowest levels of Mountain Project area designations are often extremely vague, such as "west face" or "southeast group", and don't correspond to common crag names that climbers know. I could have stopped one level up and never returned the lowest level "leaf" areas, but that algorithm isn't one size fits all. Many leaf areas are common crag designations and a level up is too general and expansive.

With all of this in mind, I ultimately chose to implement an area search that leads to an autocomplete-style search of all the nested routes under that area. The first step from a user perspective is to search by area name. This search is accomplished in exactly the same way as the route name search is, by using Ransack and returning a list of areas to choose from. But while selecting a route name opens a form to complete about the ascent, selecting an area results in another fetch to the back end for all the subroutes.

Creating a Self Join

Before I dive into that code, it's useful to understand the structure of the area table in the database. I created it with a parent/child hierarchy structure, and ActiveRecord provides some useful associations for this self join. My case was nearly identical to the manager/subordinate example given in the Ruby on Rails docs where a parent area (manager) can have many children (subordinates) but any child can only have one parent. The relationships defined in my area model simply reference the same model with the class_name key, and parent_id is the foreign key such that each child keeps track of its parent. Here's my code:

class Area < ApplicationRecord
  has_many :children, class_name: "Area", foreign_key: :parent_id
  belongs_to :parent, class_name: "Area", optional: true
  has_many :climbs

By defining my relationships like this, I now had access to parent and children methods for my Area class. Thanks Ruby! Also note that there is nothing in my models to restrict an area from having both subareas and associated routes. Mountain Project enforces this either/or on their end, so I got to seed my database knowing this was the case.

All of the Subroutes

Armed with these methods, I was then able to write a function that would return all the routes nested below an area regardless of the area 'level'. Conceptually, I wanted a method that did something like this: if an area has children areas, check all children areas. If an area has climbs, return the climbs. If this sounds like a perfect place for recursion to you, that was my thought as well, and Ruby makes the code deceptively simple:

  def all_child_routes
    routes = children.map { |area| area.all_child_routes }
    routes << climbs
    routes.flatten
  end

Let's walk through this. all_child_routes is an instance method of the Area class, so children and climbs are both methods called on the specific area. In the first line, routes is defined as an array, simply because map always returns an array. This also takes advantage of the fact that when children is called on an area that has no children an empty array will be returned (rather than nil or undefined). As the call stack resolves, climbs are pushed onto the routes array, which creates an array of arrays. (Similarly to children, climbs called on area that has no climbs returns an empty array so we don't have to worry about nil or undefined ending up in the routes array). The final line returns a flattened array, which is ultimately what we want. This code actually handles the situation where areas have both subareas and routes, although that should never be the case.

Here's another way to write this method that's a little wordier and does depend on areas never having both subareas and climbs:

  def all_child_routes
    if children
     children.map { |area| area.all_child_routes }.flatten
    else
      climbs
    end
  end

They key is to keep the arrays from becoming deeply nested, which flatten takes care of.

With these few lines of code, I was then able to return all the climbs of all the subareas of any given area!

Selecting the Climb

Back on the front end, I implemented a React component that handled autosuggestion with simple text matching. I imported react-autosuggest and customized the Autosuggest component for my particular case. The meat of the autosuggest is in the getSuggestions function:

  const getSuggestions = value => {
    const inputValue = value.trim().toLowerCase();
    const inputLength = inputValue.length;
    return inputLength === 0 ? [] : autoList.filter(climb =>
      climb.name.toLowerCase().slice(0, inputLength) === inputValue
    );
  };

where value is the user input in the text field, and autoList is the list of climbs that was returned from my previous code. The returned array of climb names is displayed in a dropdown, and when a user selects their climb of choice, the ascent details form pops up the same as if the search had begun with the climb name in the first place.

This text matching leaves a lot of room for greater sophistication, such as ignoring special characters and matching substrings that don't start from the beginning of the climb name. In some cases it would also be useful to see the full dropdown of climbs without typing any characters, but as discussed above this list would have to curated to avoid including thousands of entries. Improving this portion of the user experience is high on my list of upgrades to make to RockOn!

All together now:

GIF of user searching for area 'el cap'

More Fun with Areas

While we're on the subject of areas, I also ran into a case where I wanted to find the lowest common ancestor of a set of climbs. To be exact, I wanted to list the most specific area possible for a given day of climbing. Remember, given the extreme specificity of leaf areas on Mountain Project, routes that are a five minute stroll apart can actually be classified in different areas, never mind the case of climbing routes that are truly at separate crags. In one of my log views, climbs are grouped by date, and I wanted to list the area that was visited that day as specifically as possible.

Logbook entry with climbs from unique areas
These three climbs are found at two distinct crags but are most closely connected by Tuolumne Meadows.

I accomplished the search for most specific common ancestor by looping through the set of entries' area arrays until I found an ancestor that diverged. Helpfully, Mountain Project's API returns an array of parent areas with the climb object, ordered from least specific to most specific, so that data was already available to me. Here's my code:

  const commonArea = () => {
    let i = 0;
    while (i <= minLength) {
      let first = null;
      // found is looking for the first area that is not the same as areas get more specific
      let found = areaArrays.find(areas => {
        let area = areas[i];
        if (!first) {
          first = area;
          return false;
        } else {
          return first !== area;
        }
      })
      if (found) {
        break
      } else {
        i++
      }
    }
    return areaArrays[0].slice(0, i).join(' > ')
  }

Here minLength is the length of the shortest area array in question, and areaArrays is an array of all the area arrays that are being searched for common ancestry. The while loop breaks when an area doesn't match the first area on that 'level' or when the shortest area array has been fully traversed (i > minLength).

The area structure provided some opportunities for writing interesting and challenging code and presents plenty of further opportunities for refining the user search experience.

Next week I'll dive into what happens when a climb has finally been chosen: the ascent logging form!

Posted on by:

dianakw8591 profile

dianakw8591

@dianakw8591

Rock climber turned coder.

Discussion

markdown guide