Search code examples
hyperlinkaframeimagemap

Is it possible to use HTML image maps within an A-Frame scene?


Here's the scenario:

I have a 360º panorama image of the world map, running on A-Frame

I would like each continent to be clickable, redirecting to a related Wikipedia page.

What I tried:

  • I tried using the <map> tag as I would in a normal project, but with no results.
  • I also read the documentation, and found nothing relevant about this.

My question:

Is my goal even possible? If so, how?

My code:

Note: So far, only the African continent has coordinates.

JSFiddle

<script src="https://aframe.io/releases/1.0.4/aframe.min.js"></script>

<a-scene>
  <a-entity camera look-controls="reverseMouseDrag: true"></a-entity>
  <a-sky id="image-360" radius="10" src="https://upload.wikimedia.org/wikipedia/commons/thumb/8/83/Equirectangular_projection_SW.jpg/1920px-Equirectangular_projection_SW.jpg" usemap="#image_map"></a-sky>
  
  <map name="image_map">
    <area alt="Africa" title="Africa" href="https://en.wikipedia.org/wiki/Africa" coords="920,296 871,351 873,424 916,461 979,449 1014,463 1005,490 1032,540 1020,579 1056,663 1081,667 1114,655 1146,600 1175,561 1165,504 1220,451 1228,422 1191,426 1124,314 1063,308 1054,324 1014,302 1016,284 963,290" shape="polygon">
  </map>
  
</a-scene>


Solution

  • The <map> tag places the areas above an image. It won't project the areas onto a three dimentional object just like that.

    I'd recommend an approach where you re-use the areas defined in the <map> tag within an a-frame custom component.

    tldr:

    I've made a component that seems to do the job, and is fairly simple in use:

    <a-image material="src: #texture" asourcemap="#some-map"></a-image>
    <!-- somewhere else -->
    <map id="some-map">
       <area shape="rect" alt="rectangle" coords="0,0, 50,50" href="rect.html">
       <area shape="polygon" alt="poly" coords="0,0, 40,50, 25,0">
    </map>
    

    It should work well with the href attribute, but also the <a-image> will emit a signal with the clicked area name.

    You can see it working with 3D planes and cylinders here.

    end of tldr

    0. Gathering <map> data

    Simple parsing. Grab the <map> element, iterate through all children, and collect their data with getAttribute():

    var map = document.querySelector(selector);
    for (let area of map.children) {
       // area.getAttribute("href") - href attribute
       // area.getAttribute("alt") - alt name
       // area.getAttribute("coords") - coordinates array. 
    } 
    

    Store them for later use. The coordinates are comma separated strings, so you may need to parseInt() them, manage the order (i.e. [[x1,x1], [x2,y2], [x3, y3]])

    1. Make the a-frame entity interactable

    React on clicks, and what's more important - check where the click occurred:

    this.el.addEventListener("click", evt => {
       var UVPoint = evt.detail.intersection.uv
    })
    

    UV mapping will help us determine which point on the texture was clicked. The UV ranges from <0, 1>, so we will need to re-scale the UVPoint:

    // may need waiting for "model-loaded"
    let mesh = this.el.getObject3D("mesh")
    // this may not be available immidiately
    let image = mesh.material.map.image
    let x_on_image = UVPoint * image.width
    // the y axis goes from <1, 0>
    let y_on_image = image.height - UVPoint * image.heigth  
    

    So hey, we got the area coordinates and the point coordinates! There is only one thing left:

    2. Determining if an area was clicked

    No need to re-invent the wheel here. This SO question on checking if a point is inside a polygon has a simple inside(point, polygon) function. Actually we have everything we need, so the last thing we do is:

    • iterate through the polygons
    • check if the clicked point is inside any of the polygons
    • if positive - do your thing

    like this:

    var point = [x_on_texture, y_on_texture]
    for (var i = 0; i < polygons.length; i++) {
       // polygons need to be [[x1, y1], [x2, y2],...[xn, yn]] here
       if (inside(point, polygons[i]) {
          console.log("polygon", i, "clicked!")
       }
    }
    

    If you skipped the tldr section - the above steps are combined in this component and used in this example

    3. Old, hacky try

    Another way of doing this could be:

    • receive a click on the a-frame entity
    • grab the clicked coordinates like in 1
    • hide the scene
    • check out which <area> is at the coordinates with document.elementFromPoint(x, y);.
    • show the scene
    • create a mouse event with document.createEvent("MouseEvent");
    • dispatch it on the <area> element.

    The hide / show trick works really good even on my mobile phone. I was really surprised that the scene wasn't flickering, freezing, even slowing down.

    But document.elementFromPoint(x, y); didn't work with firefox, and probably any attempt to make it work would be way more time consuming than the 0-2 steps. Also I believe the trappings would become bigger and case-dependant.

    Anyway, here's the old-answer component:

    /* SETUP
    <a-scene>
     <a-image press-map>
    </a-scene>
    
    <image id="image" sourcemap="map">
    <map name="map">
      <area ...>
    </map>
    */
    
    AFRAME.registerComponent("press-map", {
      init: function() {
        // the underlying image
        this.img = document.querySelector("#image")
        // react on clicks
        this.el.addEventListener("click", evt => {
          // get the point on the UV
          let uvPoint = evt.detail.intersection.uv 
          // the y is inverted
          let pointOnImage = {
            x: uvPoint.x * this.img.width,
            y: this.img.height - uvPoint.y * this.img.height
          }
          
          // the ugly show-hide bits
          this.el.sceneEl.style.display = "none";
          this.img.style.display = "block";
          // !! grab the <area> at the (x,y) position
          var el = document.elementFromPoint(pointOnImage.x, pointOnImage.y);
          this.el.sceneEl.style.display="block"
          this.img.style.display="none"
    
          // create and dispatch the event
          var ev = document.createEvent("MouseEvent");
          ev.initMouseEvent(
                "click",
                true /* bubble */, false /* cancelable */,
                window, null,
                x, y, 0, 0, /* coordinates */
                false, false, false, false, /* modifier keys */
                0 /*left*/, null
          );
          el.dispatchEvent(ev);
        }
      }
    })