Idea
A while ago, I visited a look-out spot on the Holterberg. “Berg” means mountain in Dutch. In a flat country like The Netherlands we even call our smallest hills proudly mountains. Nonetheless, while I was there, I wondered which cities I could actually see in the distance. Of course, I could open up Google Maps, but I thought it was nice to create a more straightforward solution. The idea came up to create a compass showing which cities are located in different directions and their distance. I developed a small prototype in two days. My goal was to play with some web technologies (Sensor API’s, SVG rendering) and create a technical prototype.
Technical prototype concept
The phone’s compass sensor returns a value between 0-360 degrees, depending on how your phone is positioned relative to the magnetic north. Based on the positioning of the phone, the “compass base” (the view on the screen) shows the cities name in the viewport.
The “compass base” is an SVG image. For each user location (latitude, longitude), the “compass base” is unique. Based on the location of the user and the city locations, we can calculate which city should be visible on each compass bearing. We position the city name on the SVG based on that initial bearing.
Based on the phone’s direction, we get the angle. The higher that angle value, the more we translate the canvas to the left. Giving the user the experience that the canvas scrolls to the right.
SVG Canvas concept
The first thing I developed for this technical prototype was the SVG canvas concept. Moving your phone in different directions should be a seamless and endless experience. Moving from 360° to 0° and onwards and the other way round should be possible without a hick-up. You see the “edge-cases” in the picture below, where the phone is around 0 and 360 degrees. As you can see, we need to add a copy of the canvas at both sides of the “main” canvas.
In SVG you can define an object by using <def></def>
tags. With the <use> tag you can later re-use this object, multiple times throughout your SVG. So we only add elements once to our canvas, and they are display three times at different positions.
The group (g) with the compass_base id, is draw multiple times.
<svg id="compass" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 10800 600" width="10800"> <defs> <g id="compass_base"> <g id="compass_degrees"></g> <g id="compass_cardinaldirs"></g> <g id="compass_cities"></g> </g> </defs> <use xlink:href="#compass_base" x="0" y="0" /> <use xlink:href="#compass_base" x="3600" y="0" /> <use xlink:href="#compass_base" x="7200" y="0" /> </svg>
Smooth motion
If the compass is at0°, the SVG is positioned at an x of -3600px plus half of the innerWidth of the window. When the angle goes up, we translate the SVG gradually more to the left (until -7200 plus half innerWidth) so it appears that the canvas scrolls to the right.
function setCompassCenterPosition() { compassCenterPosition = -3600 + (0.5 * window.innerWidth); }
At the moment we cross from 359° to 0°, we translate the SVG translates back to the 0 position (-3600 plus half innerWidth). This looks seamless to the user, because we have a canvas at both sides.
The compass output was bit jumpy, we could smooth that with an algorithm in JavaScript (read this article on how I smooth sensor values on an Arduino). We can use requestAnimationFrame to calculate the smoothed translate animation, but it’s better to let the browser handle this. With CSS3 transitions you can create smooth animations.
Using transitions works fine, until you cross the 0° degrees point. At that point we do translate the SVG from -7200px (+ innerWidth) to -3600px (+ innerWidth). Of course the user shouldn’t notice this big translation, so we can’t have the transition at that point.
I solve this by temporary setting the transition duration to 0 and then back to the original setting (by setting a wasDisableSmoothTransition flag). You still notice the zero degrees cross mark a bit, but it doesn’t disturb the UX. For a technical prototype is sophisticated enough.
Below the moveCompass function, which takes care of the translation of the SVG element. It accepts a value between 0-360 degrees.
function moveCompass(angle) { let position = compassCenterPosition - (angle * 10); let angleDelta = lastAngle-angle; if(wasDisabledSmoothTransition) { document.getElementById("compass").style.transitionDuration = "150ms"; } if(Math.abs(angleDelta) > 180) { // crossed 0-360 line // temporary switch of CSS transitions document.getElementById("compass").style.transitionDuration = "0ms"; wasDisabledSmoothTransition = true; } lastAngle = angle; document.getElementById("compass").style.transform = 'translateX('+position+'px)'; }
Adding elements to the SVG Canvas
For this technical prototype, I only added text to the SVG, but you could add anything. In the HTML code (first code block in this article), you can see I made different groups (<g>) with the IDs: compass_degrees, compass_cardinaldirs, compass_cities.
With the addText function I can add <text> elements to those specific groups. Since the width of the SVG is 3600px, I multiply the degrees *10 to calculate the x position.
function addText(text, degrees, distance, element) { let x = degrees*10; let y = distance; let textEl = document.createElementNS(svgNS,"text"); textEl.setAttribute("x",x); textEl.setAttribute("y",y); textEl.setAttributeNS(null,"alignment-baseline","hanging"); textEl.setAttributeNS(null,"text-anchor","middle"); let textNode = document.createTextNode(text); textEl.appendChild(textNode); element.appendChild(textEl); return textEl; }
Elements will be added to the SVG in the render function.
function renderSVG(data) { // display compass angles const elCompassDegrees = document.getElementById("compass_degrees"); for (i = 0; i < 24; ++i) { let degree = i*15; addText(degree,degree,0,elCompassDegrees); } //... to be continued below
Prototyping element collision detection
One challenge was left. There are multiple cities that lie on the same angle seen from your location. Although, I didn’t work on a specific algorithm to reflect distance, I figured that I needed to prevent overlapping text. The idea is that if there is already an element on that position, the next element (with the same or a nearby compass bearing) will be moved downwards.
Luckily there is a function, getBBox(), that gives us back the bounding-box of an SVG element. We use this to check if elements overlap. If an newly added element overlaps with previous element, we move this element down (higher y-position).
The hasCollision function checks if an element (the last added one), collides with all the other elements in that specific group (#compass_cities) in the SVG.
function hasCollision(element) { const boxA = element.getBBox(); // get all the children (previously added elements) const children = document.getElementById("compass_cities").getElementsByTagName('*'); // we walk through children array in reverse // if we have one collision we jump out and return true // to prevent collision with itself (last added element) we do length - 2 for (i = children.length-2; i >= 0 ; i--) { let boxB = children[i].getBBox(); if(intersectBox(boxA,boxB)) { return true; } } return false; } function intersectBox(a, b) { return (Math.abs((a.x + a.width/2) - (b.x + b.width/2)) * 2 < (a.width + b.width)) && (Math.abs((a.y + a.height/2) - (b.y + b.height/2)) * 2 < (a.height + b.height)); }
In order to add the cities to the SVG canvas, we walk through our data (a json file with cities and their latitude longitude) and add the elements. When adding an element, we check if it collides with other elements and move the element down (higher y-position) if that’s the case.
To prevent a hanging browser, I made sure that the while-loop is not executed more than 100 times (preventEndlessLoop counter). This happend to me on iOS/MacOS while developing, I figured that getBBox on Safari returned the wrong values on a <tspan> element (that’s why I switched to <text> elements). So to be better safe than sorry I added this protection.
// continued renderSVG function // walk through the data // calculate bearing and distance based on currentLocation and city latitude/longitude // store angle and distance in the data array. data.forEach((item) => { let angle_dist = getBearingDistancePrepare(currentLocation, item.ll); item.a = angle_dist[0].toPrecision(6); item.d = Math.round(angle_dist[1]); }); // sort data on angle data.sort((a, b) => (a.a > b.a) ? 1 : -1); const elCities = document.getElementById("compass_cities"); // walk through the data and add the citie names. data.forEach((item) => { let startY = 60; const padding = 40; // Add the text to our compass_cities group. let addedChildEl = addText(item.city,item.a, startY, elCities); let preventEndlessLoop = 0; // Check if this element collides. If so, change the y-position. while(preventEndlessLoop<100 && hasCollision(addedChildEl)) { startY = startY + padding; addedChildEl.setAttribute('y',startY); preventEndlessLoop++; } });
Calculating the distance and bearing
In other to calculate the “as the crow flies” distance I used the algorithms shared by Chriss Veness (Movable Type). I combined two algorithms (distance and initial bearing), so slower cos/sin calculations can be re-used.
function toDegrees (radians) { return radians* (180 / Math.PI); } function toRadians (degrees) { return degrees * (Math.PI / 180); } // https://www.movable-type.co.uk/scripts/latlong.html // initial bearing formula function getBearingDistance(lonlatA, lonlatB) { const R = 6371e3; // earth radius in meters const latA = toRadians(lonlatA[1]); const latB = toRadians(lonlatB[1]); const lonDelta = toRadians(lonlatB[0] - lonlatA[0]); // prevent double sin/cos calculations const sinlatA = Math.sin(latA); const coslatA = Math.cos(latA); const sinlatB = Math.sin(latB); const coslatB = Math.cos(latB); const coslonDelta = Math.cos(lonDelta); // distance const distance = Math.acos(sinlatA * sinlatB + coslatA * coslatB * coslonDelta) * R; // initial bearing const y = Math.sin(lonDelta) * coslatB ; const x = coslatA * sinlatB - sinlatA * coslatB * coslonDelta; let bearing = toDegrees(Math.atan2(y, x)); if(bearing<0) bearing += 360; return [bearing, distance/1000]; }
Reader the compass with web technologies
One of the biggest challenges provided to get a reliable compass reading on Android. Apple is not known for supporting API’s for the Web, but their compass implementation is simple and gives a reliable result. Getting the compass bearing is simple a question of calling compassDir = e.webkitCompassHeading;
The only thing, is that you need to get user permission. Below the code, the startCompass function should be triggered by a user action (onClick).
function startCompass() { if (typeof DeviceOrientationEvent !== 'undefined' && typeof DeviceOrientationEvent.requestPermission === 'function') { DeviceOrientationEvent.requestPermission() .then((response) => { if (response === "granted") { window.addEventListener("deviceorientation", handler, true); } else { alert("has to be allowed!"); } }) .catch((error) => { console.error(error); alert("not supported") }); } function handler(e) { if (e.webkitCompassHeading) { compassDir = e.webkitCompassHeading; } }
I expected that reading a compass would be a trivial and easy task on Chrome Android too. Most StackOverflow answers mention that for Android just the alpha can be read (returned by the deviceorientationabsolute
event). However, I didn’t seem to get reliable results on my Android device with those values. The needle went everywhere (even after calibration movements). I couldn’t let the web compass behave like native compass apps on Android.
I spend some time checking demos and came across this awesome Marine compass demo by Rich Tibbett. This one seemed to give results that seemed to make sense (less then iOS) and the compass heading angle was similar to native apps (you have to refresh after loading, then it seems to work).
It uses his own Full-Tilt API library (most recent fork), which normalises the data. If you are interested, you should dive into Quarternions, Euler angles and more (I vaguely remember those terms from some Unity work I did). The library is not updated recently, but it seems to work ok.
Chriss Hewett made a demo which displays all the different API outputs. I also could dive into the AbsoluteOrientationSensor API, to see if that gives some better compass readings.
Continuation of this project
The prototype is not live and released. For several reasons, both in UX as technically, I’m not sure if I will continue with this project in this form.
First, I figured out that when testing it myself, I got a bit motion sick. I handed it over to some other people, and they had the same experience. Probably moving around and looking at a screen is not the most comfortable user experience. Maybe I have to come up with a different design approach to solve this.
Secondly, placing 240 world cities on the compass was a nice idea, but I think it’s pretty overwhelming in UX terms. Maybe I should stick with the Use Case I started with (cities around a point close by). Rendering should also be optimised. Currently rendering the SVG (with 240 cities) costs about 50-60 seconds on a low-end Android phone. This could be solved by moving calculations to another thread (WebWorkers) or optimising the bearing/distance calculations. I could also create a backend and do calculations server-side.
Thirdly, to use geo location and access device orientation (compass) the user needs to give permission. For a good user experience, some effort is required to create screens that explain why location and sensor data is used.
LastIy the biggest issue was the difficulty of getting reliable compass readings. All of the above doesn’t really makes sense, if I can’t get a correct compass bearing.
Final thoughts on the technical prototype
I thought the compass idea might be interesting to move around in new cities. In many (touristic) cities then have those way-finding signs that point to touristic attractions, maybe this idea could be an addition to that.
Another iteration could be for education. Let pupils select/enter a city name and guess the direction.
Feel free to contact me if you have any questions or if you are looking for solutions related to this article.