With user-friendly, portable, and affordable cameras at our fingertips and an abundance of digital storage space, we can document things anytime, anywhere…and it is all too easy to take far too many photos and videos. Cross-country skiing is one of my favorite winter pastimes, but due to improvements in cellphone cameras and action cams in recent years, it quickly became another source of high-quality videos sitting and collecting dust on my computer. I wanted to develop a new way to view, preserve, and share the footage of the beautiful trails so that all this video data doesn’t go to waste.

My family all ski the same set of trails, and most trails were named years ago by my grandfather. Because we are all familiar with the ski trails, I wanted to combine GPS data (a map of the trails) with video data and static data for each trail segment. There’s a cool app (Relive) that does something similar (overlays photos over a map of your hike, bike ride, etc. at the time when they were taken). However, I wanted to bring this a step further and create an interactive map that would allow my family to see the entire ski trail network from a bird’s eye view, hover over segments to see trail information, and then click into individual segments to see a first-person-perspective skiing video.

This post will cover how I designed the interactive map. More details about the video recording and final product will be shared in a separate post!



Table of Contents



Step 1: Create an image of the trail map

Goal: Create a map of the ski trails with segments delineated, so that I can link different data to each segment.


Step 1a: Download GPX files for trail network

When skiing, I typically use Strava, an activity tracking app, to log the details of the circuit I made. Here, Strava came in very handy because it records GPS data. Instead of having to manually draw the maps from memory, I can easily view and export GPX files of past skis to capture the exact trails.

Because I’ve never skied all the trails in one day, I exported several GPX files from my Activity page in Strava. Then, I imported each file as a new layer in a GIS software. There are several GIS programs, I am using a free option called QGIS. By combining the GPS data from multiple days of skiing, I can patch together a full map of the trails.

GPX files projected into inappropriate CRS in QGIS program.

The trails look distorted compared to the view in Strava, and I realize that I have not adjusted the coordinate reference system (CRS) in GIS, and by default it is still set to the system I was last using it for. I look up the CRS for my target region and things look much better upon adjustment.

GPX files projected into CRS matching source in QGIS program.

CRS are a solution to “flatten” a curved globe. Essentially, they cut up the world map into smaller pieces and flatten those pieces. To complicate things, there are numerous systems, which are optimized for different uses (e.g., to represent particular regions). For more on CRS, see the resources.



Step 1b: Convert GPX files to shapefiles

Here, I am struggling to find the correct language to define my problem, so my Google search queries are missing some keywords and not generating the results I am looking for. I am hoping that as long as I can find all the design elements that I want on websites that already exist, I can reverse engineer the code and to better understand how website design works.

For example, I want the map to look like one continuous line, until you hover your mouse over it, which will highlight individual segments. The answer comes to in a flash of deeply buried memories of late-night anatomy studying: websites that allow you to click on a skeleton to identify individual bones. These websites were great study aids for my comparative vertebrate anatomy class, and today I will hopefully repurpose one of these sites for a new type of learning! I track down one beautifully-designed example, a skeleton learning site hosted by Arizona State University and programmed and designed by Sabine Deviche.

  • By inspecting the source code, I learn that the skeleton is an SVG (Scalable Vector Graphic). SVG files ring a bell because when I export plots for posters or presentations, I used SVG format to maintain crisp quality at any zoom level.
  • Each bone is defined by different “path” in the graphic, which has three features:
    • “id” (the name of the bone, I can use the name of the trail segment)
    • “class” (which changes to “active” when I click on the bone. In the active state, the bone is a different color, so I assume a separate part of the code defines different attributes to the possible classes, including “active.” There can also only be one path active at a time, which is either a general default feature or has been specified elsewhere in the code.)
    • “d” (this must define the shape. These shapes are complicated enough that I assume they were drawn using software that creates SVG code from an image, rather than manually defined. Hopefully I can find a free version!)
    Example of code for an SVG skeleton viewer game found with the "Inspect Code" browser feature.

Once I find the terms “SVG” and “path”, my search results finally match what I am trying to achieve, and I discover some helpful resources that push me forward to the next step(s).

Now that I know I want to work with SVG format, I create a new Print Layout in QGIS, add my map, and save it as an SVG file. I elect to “export map layers as SVG groups” (and leave other options as their default) so that I will be able to distinguish each ski day when I open the file in Inkscape and can toggle to view them one at a time.

By using the "export map layers as SVG groups" feature in QGIS, ech map layer (day of skiing) is easily distinguished in Inkscape.

For each day’s data, I find the subfolder in Inkscape that contains the path vector, and I am excited to see that I can change the color, line thickness, and more with just a click! I was expecting to need to manually “trace” over the map, but it appears I will be able to use the vectors directly, which will save a lot of work.



Step 1c: Split the trail into segments

Strava’s “segment” feature—which allows users to create and label segments which are visible to all other users—comes in handy because many of the segments are already labelled to match the wooden sign posts. This feature also gives me easy access to static data about the trail segments (e.g., length, elevation change, and grade), making me even more tempted to try to incorporate this data.

Map of ski activity with “L’Amoreuse” segment highlighted in Strava.

Inkscape’s “View by node” feature allows me to easily break paths into smaller paths with precision. By selecting the start and end nodes for a desired path, selecting “Break path at nodes”, then “Break Path Apart”, I can cut up each day of skiing and delete repeated segments so that my final map only have one path per desired ski trail segment.

Once the segments are divided, I label them and change the color to make them distinguishable at a glance. Although I originally wanted the trails to be one color until hovering over them, I prefer how the map looks when each trail segment is a different color. In addition, I think it will be more user-friendly for viewers to orient themselves with the map. Then, I adjust the segments that need to be smoothed (e.g. remove places where the path juts out, likely due to skiing off the trail by accident or pulling off to the side to take pictures). Finally, I delete all unnecessary paths or subfolders so that my final layer contains one path per each trail segment. Now, my SVG image of the trails is complete.



Step 2: Convert map into an interactive site

Goal: Edit static map image with HTML and CSS to create a webpage with a series of interactive features.

When I open my completed SVG graphic in a text editor, I am delighted to find the HTML code is right there waiting for me. To start, I copy the code and place the svg tag within a body element–in HTML, the body defines the content to be displayed on the webpage. This adds a static image of my map to my site page.

<body>
  <svg>... code copied from opening svg in a text editor</svg>
</body>


Step 2a: Change color of trail segment on hover

Goal: When a user hovers their pointer over a trail segment, that segment color should change to highlight it.

Using CSS, users can to specify style of elements on mouse pointer hover. I try to assign path width and color on hover by adding some CSS to the head of my HTML document.

<head>
    <style>
        path:hover {
        stroke: red;
        stroke-width: 18;
        }
    </style>
</head>
  • At first, something is wrong—no changes are visible upon hovering. After some troubleshooting with the “Inspect Code” feature, I realize there is a conflict with the paths inheriting the style attributes (e.g. color, line width, etc.) I have defined in the head. In general, an element (in this case, a “path,” which represents a ski trail segment) can inherit style attributes from two places: the head (using CSS, specifies style attributes which will apply to all elements of the defined type or class) or the body (within each path). If a path has specified attribute (e.g., stroke color, stroke width) defined within the body, the style indicated in the head will not override it.

  • By removing the stroke color and width specifications for the specific path, the desired style change occurs on hover. This shows me that I don’t need to delete all style syntax from individual paths in the body, I just need to remove the attributes that will conflict with the head.


Knowing this, I now have two complications to resolve:

  1. If I remove the stroke color from the individual paths so that they can inherit the “hover” attributes from the head, I need to specify the color elsewhere for when there is no pointer hovering over the path. I can do this in the head style element, but then all my paths will be the same color, unless I manually list out each path in the head.
  2. It is a difficult to hover exactly over a path due to how narrow they are. If I can add some kind of “buffer” to increase the area of hover effect, it may be easier for users to hover over and click on paths.

To solve both these problems, I go back to Inkscape and duplicate all the paths. I place these new paths in a separate layer labelled “Buffer.” The style specified in Inkscape does not matter here because I will be removing these attributes in each individual path and specifying it in the head (I could also have done this step directly in my code editor). By setting the opacity to 0.5, the path now looks like it is being highlighted on hover.

<head>
  <style>
    path {
      stroke: #ffffff;
      stroke-width: 60;
      stroke-opacity: 0;
    }
    path:hover {
      stroke: orange;
      stroke-width: 40;
      stroke-opacity: 0.5;
    }
  </style>
</head>

<body>
  <svg>
    <g>
      ...base layer with style specified for each path. Thus, they do not
      inherit the attributes specified in the head.
    </g>
    <g>
      ..."buffer" layer with no style specified for stroke color, width, and
      opacity. Thus, these paths will inherit these style attributes from the
      head.
    </g>
  </svg>
</body>


Step 2b: Display trail name on mouse hover

Goal: When a user hovers their pointer over a trail segment, that segment name should appear.

Using this project as a guide, I learn that one possible option is to add an anchor tag (a) and an “xlink:title” attribute. On hover, the title of the anchor tag is displayed.

Warning: the “xlink:title” attribute is now deprecated, and should be replaced with a child title element to ensure compatibility with all browsers.

With this route, I won’t be able to add additional details (e.g. trail length), but for now it works to orient viewers to which path is which.

Bonus step: Learning Java Script!

When talking to my brother about manually going back to add anchor tags around each path, he suggested that if each path contains the same set of attributes, it may be worth it to try using JavaScript to make a loop to generate the path code. That way, if I wanted to make a change that applies to all paths, I could just write it once in the loop and it would apply to all paths. This had an added benefit of cleaning up the code considerably, and allowing me to remove some unnecessary style attributes that were included by Inkscape.

  • Troubleshooting: My paths were generating with no “shape”. I realized they were being created as HTML elements, not SVG elements, so the “d” attribute was recognized as defining the shape. To resolve, I needed to create the element within an SVG namestate (NS). Troubleshooting source.

    • The same thing applies when adding an anchor (a) tag: you must define it as an SVG anchor, not an HTML anchor, as above.

      // This code will generate the paths as HTML elements:
      const newNode = document.createElement("path");
      
      // This code will generate the paths as SVG elements becuase we have defined the SVG namespace (NS):
      const newNode = document.createElementNS(
        "http://www.w3.org/2000/svg",
        "path"
      );
      


  • To add text to appear on hover, you must add a title element as a child under the anchor tag and make sure to use “textContext”, not “innerText” to define the titles. Troubleshooting source.


Code to create my paths using JavaScript.

<script>

const data = [
    {
      id: "path38",
      label: "Le Début",
      shape:
        "m 802.3064,1016.0114 0.71035,-0.4026 1.14205,-0.3017,  ..."
    },
    {
     // continue to define the id, label, and shape for all paths
    },
];

const canvas = document.getElementById("bufferLayer");
  for (item of data) {
    // To generate SVG elements, need to create element within the SVG namestate (NS), otherwise it will generate as an HTML element.
    var a = document.createElementNS("http://www.w3.org/2000/svg", "a");
    title = document.createElementNS("http://www.w3.org/2000/svg", "title");
    // If you use "innerText" instead of "textContent," the title is not visible in the rendered website.
    title.textContent = item.label;
    a.appendChild(title);
    const newNode = document.createElementNS(
      "http://www.w3.org/2000/svg",
      "path"
    );
    // Each new node (here, "path") generated by this script will include the attributes specified below.
    // New attributes can be unique to each path (in which case, values are specified in the array above) or a constant value.
    newNode.setAttribute("id", item.id);
    newNode.setAttribute("inkscape:label", item.label);
    newNode.setAttribute("class", "inactive");
    newNode.setAttribute("d", item.shape);

    a.appendChild(newNode);
    canvas.appendChild(a);
  }
</script>


Step 2c: Display video on click

Goal: When a viewer clicks on a trail segment, a video of that segment will appear.

Problem: GitHub repos can only store 1GB of data. I expect each trail video to be at least several hundred MB, due to video quality and length. Even if the videos were smaller, I don’t want the videos to be publicly available. (NOTE: it is possible to make a GitHub repository private, but the free version of GitHub Pages does not allow for sites to be hosted from private repositories.)

Solution: I will store my videos on Google Drive, where 1) I have ample storage space, and 2) there is built-in authentication (i.e. only Google accounts that have permission to access the Drive folder storing the video files will be able to view the videos, even when accessing them from through website).


Problem: Upon first pass, I add the links to some test videos in the anchor tag for the corresponding trail segment (SVG path). This directs the viewer to a new page, which is OK but may be a bit confusing for someone like my grandparents to realize they need to click the back arrow to view the map again.

Solution: I create an iframe, and in my anchor link with the video URL, I add a target (which I link to the iFrame by assigning it a “name” attribute). Now clicking the trail segment causes the video link to load in the iFrame.

Note: If you want to link to a Google Drive video, be sure to copy the “embed video” link, otherwise the authentication from the browser will not pass to the frame.


Problem: The videos, when stored on Google Drive, will load if they are opened in a new tab, but they do not load inside the iframe. This is because most modern browsers block embedded content (i.e. content in an iframe) from requesting access to cookies. As far as I can tell, this is blocking the Google log-in information from passing to the iframe, which blocks access to the videos.

Solution: the Storage Access API allows cross-site iframes to access access unpartitioned cookies, if the necessary permissions have been granted to the iframe. This gets me part of the way there, but there is still an issue with the pop-up sign-in window not appearing. To resolve this, I allow popups to escape the iframe.

<iframe
  sandbox="allow-storage-access-by-user-activation
             allow-scripts
             allow-same-origin

             allow-top-navigation

             allow-popups
             allow-popups-to-escape-sandbox"
>
</iframe>


Step 2d: Improve relative positioning of iframe and map

Goal: Make the sizes of the map (SVG) and the video display (iframe) relative so that in any size browser window you see the two side-by-side.

Using CSS, I can take advantage of the flexbox layout module. After defining which elements are part of “child” and “parent” containers, I make the map and iframe inline, change the vertical justification, set the relative size, and define a minimum width after which the containers switch from horizontally inline to vertically inline.



Step 2e: Change color of path when clicked

Goal: Define a new style for the “active” state of a path (i.e. when the trail segment is clicked), so viewers can easily remember which trail corresponds to the video they are watching.

  • Set up a function to change the color of a path on click.
    • Define two class styles: “inactive” and “selected” (arbitrary names).
    • In JavaScript, define a class attribute when I am creating the paths in the buffer layer so that paths are assigned “class=inactive” in their default state.
    • Add a function and eventHandler (event = click) so that when a path is clicked, the class changes to “selected”.
  • Once again, there is an issue where HTML and SVG elements cannot be equated. For example, I set up a function so that on click, the className changes. However, “className” changes the class of HTML elements. To change the class of an SVG element, I need to use “className.baseVal” as pointed out by this StackOverflow post.
  • I also want only one path to have the “selected” class at once.
    • The “hover” style is taking precedence over the class style, so even if the path changes to red once it is clicked, it still goes bac to orange when hovered over. So instead of “path:hover”, let’s replace it with “.inactive:hover” so that it only applies to path in the inactive class.
const newNode = document.createElementNS("http://www.w3.org/2000/svg", "path");

var lastClickedPath;
// Create a function that will change the class of paths from "selected" to "inactive".

function changeClass(event) {
  // Only want one path to have the class "selected" at a time.
  for (item of document.getElementsByClassName("selected")) {
    // For SVG elements, className is an SVGAnimatedString. SVGAnimatedString has two main properties: baseVal and animVal.
    // "target" refers to the target of the event which called the function, which in this case is the path that was clicked.
    if (event.target.id !== item.id) {
      item.className.baseVal = "inactive";
      // If you click on a new path, the previous path will become "inactive".
    }
  }
  if (
    event.target.id === lastClickedPath &&
    event.target.className.baseVal === "selected"
  ) {
    event.target.className.baseVal = "inactive";
    changeVideo(event.currentTarget);
    // If you click on the same path twice, it will revert to "inactive" on the second click.
  } else {
    event.target.className.baseVal = "selected";
    // If the path was not targeted on the previous click, the path will become "selected".
  }
  // Store the id of the element that was targeted by the event (i.e. the most recently clicked path).
  lastClickedPath = event.target.id;
}


Step 2f: Add instructions for users

Goal: Add a “welcome” message to the iframe that will display when the page is loaded for the first time.

I create a new page on my website that displays basic instructions. Then, I set the src for the iframe to that URL. Now, when there is no other element targeting the iframe (i.e. the anchor tag with the video links), the iframe displays the instructions page.



Step 2g: Add a satellite view

Goal: Add a toggle switch to show or hide a satellite underlay under the map.

To make the satellite image overlay perfectly over the trails took some trial-and-error. First, I found the satellite image in QGIS, using one of the free built-in sources (ESRI). Then, I exported the map as a png, which I loaded into and Inkscape file with my SVG paths overlaid. Then, was able to move the map until it fit perfectly over the trails. Then, using the rectangle tool in Inkscape, I created a rectangle with the same aspect ratio as the “map” child container on my site. This allowed me to crop the map image to the correct ratio so that the image fits perfectly.

Once the image was ready, I followed these two guides to create a toggle switch with CSS and link the state of the switch to my desired event with JavaScript.

As a result, when the switch is toggled it changes the class of the map container. For the class “satelliteOn,” the satellite image is set as the background for the container. And when the switch is toggled off, the container has no background image.

<head>
    <style>
        .satelliteOn {
        background-image: url(https://rkhouri.github.io/assets/img/SkiMap-sattelite-inkscape.png);
        background-size: contain;
        }
    </style>
</head>
<script>
  document.addEventListener("DOMContentLoaded", function () {
    var checkbox = document.querySelector('input[type="checkbox"]');
    checkbox.addEventListener("change", function () {
      if (checkbox.checked) {
        document.getElementById("map").className = "child satelliteOn";
        console.log("Checked");
      } else {
        document.getElementById("map").className = "child";
        console.log("Not checked");
      }
    });
  });
</script>


Step 2h: Add a “deselect” feature

A request from a fan: add “deselect” functionality…i.e. when clicking on a path a second time in a row, revert it to the active state. This was a request from a lovely beta tester who “hates when websites let you click on something but not unclick it.” This took A LOT of trial and error only to come to an incredibly simple solution.

  • At the end of the ChangeClass function, I add a command to store the latest event ID as a variable (lastClickedPath). That way, for the next click I can see if I am clicking on the same path or a new one and differentiate. If it is a new path (i.e. event.target ID does NOT match lastClickedPath) the class changes to “selected”. If it is the same path (i.e. event.target ID does match lastClickedPath) then the class changes to “inactive”.

    // Make a new variable where we can store the id of the most recently clicked path. 
    // This allows us to implement "deselection" when a path is clicked a second time.
    var lastClickedPath;
    
    // Create a function that will change the class of paths from "selected" to "inactive".
    function changeClass(event) {
      // I only want one path to have the class "selected" at a time.
      for (item of document.getElementsByClassName("selected")) {
        // If you click on a new path, the previous path will become "inactive".
        // "target" refers to the target of the event which called the function, which in this case is the path that was clicked.
        if (event.target.id !== item.id) {
          // For SVG elements, className is an SVGAnimatedString. SVGAnimatedString has two main properties: baseVal and animVal.
          item.className.baseVal = "inactive";
        }
      }
      if (
        // If you click on the same path twice in a row, it will revert to "inactive" on the second click.
        event.target.id === lastClickedPath &&
        event.target.className.baseVal === "selected"
      ) {
        event.target.className.baseVal = "inactive";
        changeVideo(event.currentTarget);
      } else {
        // If the path was not targeted on the previous click, the path will become "selected".
        event.target.className.baseVal = "selected";
      }
      // Store the id of the element that was targeted by the event (i.e. the most recently clicked path).
      lastClickedPath = event.target.id;
    }
    
    // Add an event listener to each path so that the changeClass function is called on click of these paths.
    newNode.addEventListener("click", changeClass);
    

I also wanted to the video to stop playing when a path is deselected, and for the iframe to revert to the instructions. To do so, I had to change the way in which the iframe was directed to the video URLs. Originally, I had the links to each path’s video listed in the respective anchor elements, which were set to target the iframe on click. I ended up removing the href from the anchor elements and instead using JavaScript to change the iframe src to each path video on click, and to revert to the instructions URL when a path is deselected.



Step 2i: Create label to identify which video is playing

Goal: Add a label with text that changes to reflect which path is currently selected.

On mobile browsers, there is no support for hover, meaning that mobile users would not be able to see the trail name. Additionally, even on a computer, it would be helpful to see clearly which video you are watching. For these reasons, I used js to add a label above the video player that inherits its text from the “label” attribute of the path that is currently selected. When no video is playing, the text disappears.



Step 2j: Address cross-browser compatibility issues

With the site working perfectly on Firefox, I was ready to pat myself on the back…until I opened the page in Chrome and found two major errors:

  • Instructions page was not displaying in the iframe, due to a Mixed content error (error message: “content was loaded over https but requested an insecure resource”).
    • Solution: following a suggestion on StackOverflow I added “/index.html” to the end of the URL and this resolved the problem.
  • Class name was not changing on click. The paths would change color when clicked, but in the attribute list, the class would not change and thus the path would stay red even after a new path was clicked. This meant the class was changing at some level, but was not being correctly reassigned.
    • Solution: By changing my JavaScript syntax to use the setAttribute function, the elements now respond properly and the class name changes on click.

      // Sample of previous syntax:
      event.target.className.baseVal = "inactive";
      
      // Updated syntax for compatibility with Chrome:
      event.target.setAttribute("class", "inactive");
      


Summary

In a digital age, it can be difficult to make personal videos feel valuable. Though a return to physical (e.g. by burning videos onto DVDs) can make family videos feel precious and memorable, we can also leverage digital tools to display videos in a unique way. By incorporating videos into an interactive map, we can use GPS data (which can be easily recorded with smart phones) to add value to videos of places which are special to us. Though this project was very specific to my personal use, the concept could be applied to many uses, such as documenting your favorite hiking trails, or storing memories of your favorite running paths through a city.

Future enhancements to the map:

  • Add color gradient to reflect incline grade
  • Incorporate more data about the trails (i.e. length, total elevation change, seasonal “records”,etc)

The full code for this project is available in my GitHub repository. A demo of this site can be found here.



Learning Roundup

New Skills Refresher Skills
SVG image editing (Inkscape) Markdown
CSS GIS
JavaScript  
HTML  

This project was a great morale boost. I started with an idea, with no assurance that it was actually possible to implement and no idea what tools I would need to achieve my goal. The fact that I succeeded (and learned a ton along the way) has me excited for the future projects yet to come. I also really felt the “playground slide” effect that I describe in my first blog post, which was the inspiration for my blog logo. There were many many days of “climbing the ladder” (and falling off a few times) that were tiring and frustrating. But with every small success, the rush of joy as I “slid down the slide” and saw the newest feature working, I was rushing right back to the ladder to climb again.

It was also gratifying to see past knowledge from research projects come into play in unexpected ways. For example, I only thought to check the CRS of my map in GIS because my Master’s thesis included analyzing geographic coordinates, which required transforming CRS’s to combine data from different sources. As a result, I knew that if a map looks distorted, it’s probably because the CRS of the visualization doesn’t match the source data.

I also learned many versatile skills that I look forward to using in the future:

  • I had no experience with JavaScript when I started this project, so I must give a shout-out to Stack Overflow as well as my brother for the crash course he gave me. I may have spent more time trying to write JavaScript for things I could have manually added to my code, but it felt great to learn a new and more efficient way to solve these problems.

  • This project was a great foray into web design. It was also a great intro to troubleshooting web design. Before this, the only time I had used the “Inspect Code” feature on a site was if I accidentally activated it and then would close it out of panic that I was going to break something.

  • Learning how to create and manipulate SVG images with Inkscape or with HTML will be a helpful tool to create vector graphics in the future.



Required Software

  • GIS program (I used QGIS, version 3.34.0-Prizren)
  • Vector drawing program (I used Inkscape, version 1.3.1)
  • Not required, but highly recommended: a coding software that allows for a live preview of HTML sites. I use the “Live Server” extension in VS Code, which launches a local development server with live reload. So, when you make changes to your HTML file, you can see the result without needing to push a commit to GitHub. This saved me from hundreds of unnecessary commits to make minute adjustments or to test code that didn’t end up working. Not to mention the time saved from not needing to wait for GitHb Pages to redeploy the site after every commit.


Resources