This is the third and final part of the in depth guide started by Editing the Virtual Easter Egg Hunt. In the previous post we set up a simple page to display a panorama, detect clicks, alert, and mark eggs.

In this post we will be adding instructions that will show on page load, add a control bar, improve the look of the pop-ups, and allow for moving between two images.

Restructure Layout

In the previous guide the layout of the files was very simple since there was only the panorama and index.html file to keep track of. It is possible to keep all the HTML, CSS, and javascript in a single file, but it can get cluttered. There will be new js and css folders with corresponding files in each. While we will only use a single css and js file, the folders allow for future expansion. After these changes your folder structure should look like the below image.

With the new files created the contents of the index.html will be moved to the appropriate files. The files should look like the following, and everything should still look the same as the previous guide when loaded in the browser.

<!DOCTYPE html>
<html lang="en">
<head>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/photo-sphere-viewer@4/dist/photo-sphere-viewer.min.css"/>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/photo-sphere-viewer@4/dist/plugins/markers.css"/>

    <link rel="stylesheet" href="css/egg.css"/>

    <script src="https://cdn.jsdelivr.net/npm/three/build/three.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/uevent@2/browser.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/photo-sphere-viewer@4/dist/photo-sphere-viewer.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/photo-sphere-viewer@4/dist/plugins/markers.js"></script>

    <script src="js/egg.js"></script>
</head>
<body>
    <div id="viewer"></div>
      
    <script>
        var viewer = new PhotoSphereViewer.Viewer({
          container: document.querySelector('#viewer'),
          panorama: 'images/courtyard.jpg',
          panoData: {
            fullWidth: 14440,
            fullHeight: 7220,
            croppedWidth: 14440,
            croppedHeight: 3299,
            croppedX: 0,
            croppedY: 1961,
            poseHeading: 0, // 0 to 360
            posePitch: 0, // -90 to 90
            poseRoll: 0, // -180 to 180
          },
          plugins: [
            [PhotoSphereViewer.MarkersPlugin, {}]
          ]
        });

        const markersPlugin = viewer.getPlugin(PhotoSphereViewer.MarkersPlugin);

        viewer.on('dblclick', function(e, data){
          registerClick(data, markersPlugin);
        });
    </script>
</body>
</html>
var eggs = [
    [-0.12587820712970976, 5.510338553759633, 0],
    [-0.028580325945409157, 5.445477327995103, 0],
    [0.053453344915963985, 5.5273236466781634, 0],
    [-0.26486446083318516, 5.881107170695541, 0],
    [0.007088363198832104, 5.822068560570339, 0],
    [-0.12362573067266247, 0.5002947765628827, 0],
    [-0.15758379816854595, 0.7082625359322156, 0],
    [-0.19612747796709296, 0.9357689626661057, 0],
    [-0.10673168638709907, 0.9813403942572025, 0],
    [0.18049113609768863, 0.6241884176319561, 0]
]

function checkEggs(lat, long, markers) {
    eggs.forEach(function (egg, index) {
        var latDiff = Math.abs(egg[0] - lat);
        var longDiff = Math.abs(egg[1] - long);

        if((latDiff < 0.02) && (longDiff < 0.015)) {
            if(egg[3] == 1) {
                alert("Egg already found.");
            } else {
                alert("You found an Egg!");
                egg[3] = 1;

                markers.addMarker({
                    id: `egg=${index}`,
                    circle: 20,
                    latitude: egg[0],
                    longitude: egg[1],
                    svgStyle: {
                        opacity: '0.5',
                        stroke: 'blue',
                        strokeWidth: '3px',
                        fill: 'light-blue'
                    }
                });
            }
        }
    });
}

function registerClick(data, markers) {
    console.log("Double Click");
    console.log(`Lat: ${data.latitude}`);
    console.log(`Long: ${data.longitude}`);

    checkEggs(data.latitude, data.longitude, markers)
}
/* the viewer container must have a defined size */
#viewer {
  width: 100vw;
  height: 80vh;
}

Instructions Included

Adding directions that are present when someone lands on the page is helpful for two reasons. The first is that it gives you a chance to explain how to interact with the egg hunt along with any other information you wish to convey. The second reason is a little sneakier. You can use the instructions and the time it takes someone to read them to hide the loading of the first panorama.

We will be building a modal to hold the instructions. When the modal is visible it will cover the entire page so that the user cannot see the image loading. The first part of the modal is the HTML, which will include a button to close the modal.

<div id="startup">
    <div id="instructions">
        <p>The instructions you wish to provide go here.</p>
        <p>You can also place additional HTML to format the look of the instructions</p>
        <span id="ok" onclick="closeInstructions();">OK</span>
    </div>
</div>

Next is the CSS to have the modal display over top of everything and take up the entire screen. There is also some CSS formatting for the Ok button to give it a more button look. Finally there is a change to the viewer CSS to have it start out as hidden.

#viewer {
    width: 100vw;
    height: 80vh;
    visibility: hidden;
} 

/* The Modal (background) */
#startup {
    display: block; /* Hidden by default */
    position: fixed; /* Stay in place */
    z-index: 1; /* Sit on top */
    left: 0;
    top: 0;
    width: 100%; /* Full width */
    height: 100%; /* Full height */
    /*overflow: auto; /* Enable scroll if needed */
    background-color: rgb(0,0,0); /* Fallback color */
    background-color: rgba(0,0,0,0.4); /* Black w/ opacity */
}

/* Modal Content/Box */
#instructions {
    background-color: #fefefe;
    margin: 2% auto; /* 15% from the top and centered */
    margin-bottom: 0%;
    border: 1px solid #888;
    width: 80%; /* Could be more or less, depending on screen size */
    height: 90%;
    display: flex;
    justify-content: center;
    align-items: center;
    flex-direction: column;
    overflow-y: auto;
}

/* The Close Button */
#ok {
    width: 20%;
    color: black;
    float: right;
    font-size: 28px;
    font-weight: bold;
    border: 4px solid blue;
    background-color: lightgrey;
    text-align: center;
}

#ok:hover,
#ok:focus {
    color: grey;
    text-decoration: none;
    cursor: pointer;
    background-color: lightblue;
} 

The final part is the javascript so that clicking the close button actually closes the modal.

function closeInstructions() {
    var modal = document.getElementById("startup");
    var viewerEl = document.getElementById('viewer');

    modal.style.display = "none";
 
    viewerEl.style.visibility = "visible";
}

At this point you can update the instruction to meet your needs by editing the HTML inside the modal. For the Virtual Easter Egg Hunt that meant a background and images for the various buttons with explanations of their function. The basic instructions created above should look like the following when the page is loaded.

Control Bar

For an Easter egg hunt it’s useful for the user to be able to do things beyond just moving around the panorama and clicking on eggs. The control bar adds a location to house functions like reset, egg basket, and move for when we have multiple panoramas.

The control bar itself is a flexbox that occupies the remaining screen space below the panorama. Controls can be added or removed by modifying how many control elements are in the control bar.

<div class="controls">
    <div id="info" class="control" onclick="openInfo();">
        <img src="images/info.png">
    </div>
    <div id="basket" class="control" onclick="checkBasket();">
        <img src="images/basket.png">
    </div>
    <div id="map" class="control" onclick="loadMap();">
        <img src="images/location-162102_1280.png">
    </div>
    <div id="reset" class="control" onclick="resetHunt();">
        <img src="images/reset.png">
    </div>
</div>

The CSS is fairly basic to have the controls sit side by side along with having a defined border between each control. I also include a CSS piece to set images to be full height of their respective container.

.controls {
    display: flex;
    flex-direction: row;
    height: 15vh;
    justify-content: center;
}

.control {
    border: 5px;
    border-style: solid;
    border-color: gray;
    height: 100%;
    width: 10%;
    text-align: center;
    cursor: pointer;
}

img {
    height: 100%;
}

Each control element is tied to a javascript function that will carry out the controls task. For something like a reset that can be as simple as clearing the egg markers and data. For functions like showing the basket the javascript will handle bringing a pop-up to the foreground.

function checkBasket() {
    var msg = "You have found " + basket.eggs + " Egg";
        
    if (basket.eggs > 1) {
        msg = msg + "s";
    }

    basketModal(msg);
}

function resetHunt() {
    markers.clearMarkers();
    basket.eggs = 0;

    var locs = Object.values(locations);
    for (loc of locs) {
        loc.eggs.forEach(function (egg, index) {
            egg[3] = 0;
        });
    }
}

For my control bar I used images to indicate the various controls. Once everything above has been added you should see a control bar similar to below on the page after you close the instructions.

Better Pop-ups

While using javascript alerts work for putting information in front of the user they have the disadvantage of being blocked by browsers in some cases. You also can’t change the appearance of the javascript alert. To build better pop-ups we’ll be using modals similar to the instructions that are shown on load.

The first part of setting up the pop-ups is building up the HTML for each of the pop-ups you’ll need. For the Easter egg hunt pop-ups for an egg being found, the basket, and a pop-up for moving panoramas. For all the pop-ups the basic structure is the same.

<div id="egg-modal" class="modal">
    <div id="egg-content" class="modal-content">
        <img id="egg-img" src="images/egg1.png">
        <div id="egg-msg" class="modal-msg"></div>
    </div>
</div>
    
<div id="basket-modal" class="modal">
    <div id="basket-content" class="modal-content">
        <img id="basket-img" src="images/basket.png">
        <div id="basket-msg" class="modal-msg"></div>
    </div>
</div>

The pop-ups also share a common CSS styling so they have a consistent look.

.modal {
    display: none;
    position: fixed; /* Stay in place */
    z-index: 1; /* Sit on top */
    left: 0;
    top: 0;
    width: 100%; /* Full width */
    height: 100%; /* Full height */
    overflow: auto; /* Enable scroll if needed */
    background-color: rgb(0,0,0); /* Fallback color */
    background-color: rgba(0,0,0,0.4); /* Black w/ opacity */
}

.modal-content {
    display: flex;
    flex-direction: row;
    justify-content: center;
    align-items: center;
    height: 20%;
    margin: auto;
    background-color: lightblue;
    width: 25%;
}

.modal-msg {
    text-align: center;
    font-size: 28px;
    font-weight: bold;
}

Now we need to add a function to display the new pop-up as well as update the code to call this new function when an egg is found.

function eggModal(msg) {
    var eggModal = document.getElementById('egg-modal');
    var eggMsg = document.getElementById('egg-msg');

    eggMsg.innerText = msg;

    eggModal.style.display = "block";
}

function checkEggs(lat, long, markers) {
    eggs.forEach(function (egg, index) {
        var latDiff = Math.abs(egg[0] - lat);
        var longDiff = Math.abs(egg[1] - long);

        if((latDiff < 0.02) && (longDiff < 0.015)) {
            if(egg[3] == 1) {
                eggModal("Egg already found.");
            } else {
                eggModal("You found an Egg!");
                egg[3] = 1;

Finally there is a bit of javascript to handle closing the pop-up when the user clicks away. This is handled through the PSV library since the pop-up is on top of the viewer. There is also a function added to check for clicks outside of the PSV window.

viewer.on('click', function(data){
    closeModal(data);
});
window.onclick = function(event) {
    var eggModal = document.getElementById('egg-modal');
    var basketModal = document.getElementById('basket-modal');

    if (event.target == eggModal) {
        eggModal.style.display = "none";
    }

    if (event.target == basketModal) {
        basketModal.style.display = "none";
    }
} 

function closeModal(data) {
    var eggModal = document.getElementById('egg-modal');
    var basketModal = document.getElementById('basket-modal');

    eggModal.style.display = "none";
    basketModal.style.display = "none";
}

Once everything is in place you should start seeing a pop-up like below for when an egg is found. That pop-up should also disappear on the next click.

Moving Between Panoramas

The final piece that makes up the virtual Easter egg hunt is the ability to move between two panoramas. This is accomplished through javascript to handle the change in egg markers and image, and a pop-up to hide the activity from the user. While the pop-up isn’t required I felt that it made the experience better as the user had a better feeling for what was going on.

The pop-up HTML is similar to the other pop-ups and uses the same CSS for styling.

<div id="movement-modal" class="modal">
    <div id="movement-content" class="modal-content">
        <img id="movement-img" src="images/map-picnic.jpg">
    </div>
</div>

The javascript is triggered from the control bar and handles swapping everything out then closing the pop-up when done. To handle keeping track of all the moving parts we will need to add a new object with the panorama names, as well as updating the eggs object to have eggs by location.

var locations = {
    courtyard: {
        panorama: 'images/courtyard.jpg',
        eggs: [
            [-0.12587820712970976, 5.510338553759633, 0],
            [-0.028580325945409157, 5.445477327995103, 0],
            [0.053453344915963985, 5.5273236466781634, 0],
            [-0.26486446083318516, 5.881107170695541, 0],
            [0.007088363198832104, 5.822068560570339, 0],
            [-0.12362573067266247, 0.5002947765628827, 0]
        ]
    },
    picnic: {
        panorama: 'images/picnic.jpg',
        eggs: [
            [-0.5411422879785617, 5.040059193272677, 0],
            [-0.19165482472353412, 5.567485509988917, 0],
            [-0.03885405885440729, 5.4380490621106325, 0],
            [-0.06573531664068066, 5.455813268801782, 0],
            [-0.08358934776027893, 5.809494124320525, 0],
            [-0.05399251511869396, 5.638968077540507, 0]
        ]
    }
};

var currentLoc = locations.courtyard;

Next is the loadMap function that is called from the control bar. This is also where the next location is determined if you wanted to move through more than two images it could be handled here. There is also an event handler to call the closeMovement function once the panorama has completed loading.

viewer.on('panorama-loaded', function() {
    closeMovement();
});

var nextLoc = 'picnic';

function loadMap() {
    openMovement(nextLoc);

    changePanorama(nextLoc, viewer, markersPlugin);

    if(nextLoc == 'picnic') {
        nextLoc = 'courtyard';
    } else {
        nextLoc = 'picnic'
    }
}

Finally we have the functions to handle displaying the pop-up and actually rotating the image and markers out.

function openMovement(loc) {
    var movementModal = document.getElementById('movement-modal');
    var movementImg = document.getElementById('movement-img');
    var viewerEl = document.getElementById('viewer');

    movementImg.src = "images/map-" + loc + ".jpg";
    movementModal.style.display = "block";
    viewerEl.style.visibility = "hidden";
}

function closeMovement() {
    var movementModal = document.getElementById('movement-modal');
    var modal = document.getElementById("startup");
    var viewerEl = document.getElementById('viewer');

    movementModal.style.display = "none";

    if(modal.style.display == "none") {
        viewerEl.style.visibility = "visible";
    }
}

function changePanorama(loc, viewer, markers) {
    currentLoc = locations[loc];

    viewer.setPanorama(currentLoc.panorama);
    markers.clearMarkers();

    currentLoc.eggs.forEach(function (egg, index) {
        if(egg[2] == 1) {
            markers.addMarker({
                id: `egg=${index}`,
                circle: markerSize,
                latitude: egg[0],
                longitude: egg[1],
                svgStyle: {
                    opacity: '0.5',
                    stroke     : 'blue',
                    strokeWidth: '3px',
                    fill: 'light-blue'
                }
            });
        }
    });
}

If everything worked you will be able to move back and forth between panoramas. During the move you will be greeted by a pop-up similar to the below.

Wrapping Up

Now you should have a virtual Easter egg hunt that functions similar to the one I built. You can continue expanding on this basic design to change it into other functions like a monument hunt in DC. You can also make adjustments to the CSS to have the page adjust for mobile browsers. Have fun with it and expand in any way you like. The point of this guide has been to show you how I did things, but should be taken as a jumping off point for any idea you have dealing with displaying panoramas in a browser.

If you are looking to just try building the code and don’t want to go looking for images to use you can get copies of the ones I used from the github repo for the virtual Easter egg hunt. You can also browse the code to get the locations of the eggs and other pieces of information.