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:
<map>
tag as I would in a normal project, but
with no results.My question:
Is my goal even possible? If so, how?
My code:
Note: So far, only the African continent has coordinates.
<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>
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:
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:
<area>
is at the coordinates with document.elementFromPoint(x, y);
.document.createEvent("MouseEvent");
<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);
}
}
})