QutBioacousticsResearchGroup/bioacoustic-workbench

View on GitHub
app/assets/javascripts/angular/controllers/experiments.js

Summary

Maintainability
F
1 wk
Test Coverage
"use strict";

;
(function (undefined) {
    var app = angular.module('bawApp.controllers');

    app.controller('ExperimentsCtrl', ['$scope', '$resource', '$routeParams', '$route', '$http', 'Media', 'AudioEvent', 'Tag',

        /**
         * The Experiments controller.
         * @param $scope
         * @param $resource
         * @param $routeParams
         * @param AudioEvent
         * @constructor
         * @param Tag
         * @param Media
         * @param $route
         * @param $http
         */
            function ExperimentsCtrl($scope, $resource, $routeParams, $route, $http, Media, AudioEvent, Tag) {

            $scope.PREFACE_STAGE = "Welcome";
            $scope.EXPERIMENT_STAGE = "Activity";
            $scope.FINAL_STAGE = "Conclusion";

            $scope.results = {
                allowContact: true,
                consented: false,
                ethicsStatementViewed: false,
                pageHit: (new Date()).toISOString(),

            };
            $scope.errors = [];
            $scope.spec = {
                experimentSteps: []
            };
            $scope.stage = $scope.PREFACE_STAGE;
            $scope.step = 0;
            $scope.resultsSending = false;
            $scope.resultsSentSuccessfully = undefined;


            // download experiment protocol
            var experiment;
            if ($routeParams.experiment == "tour") {
                experiment = '/experiment_assets/bird_tour.json';
            } else if ($routeParams.experiment == "rapidScanExtended") {
                experiment = '/experiment_assets/rapid_scan_long.json';
            } else {
                experiment = '/experiment_assets/rapid_scan.json';
            }
            experiment += "?antiCache=" + Date.now().toString();
            $http.get(experiment).
                success(function (data, status, headers, config) {
                    $scope.spec = data;
                    $scope.results.experiment = $scope.spec.experiment;

                    if ($routeParams.cheat) {
                        $scope.stage = $routeParams.cheat;
                        if ($scope.stage == $scope.EXPERIMENT_STAGE) {
                            $scope.step = 1;
                        }
                    }

                    if ($scope.spec.additionalResources) {
                        downloadOtherResources();
                    }

                }).error(function (data, status, headers, config) {
                    alert("downloading test specification failed");
                });

            function downloadOtherResources() {
                var maxAttempts = 5;

                function downloadRecursive(attemptsLeft, resource, storeProperty) {
                    if (attemptsLeft > 0) {
                        $http.get(resource + "?antiCache=" + Date.now().toString())
                            .success(function (data, status, headers, config) {
                                $scope.spec.additionalResources[storeProperty] = data;

                                console.info("Downloading resource " + resource + " succeeded.", data);
                            })
                            .error(function (data, status, headers, config) {
                                console.error("Downloading resource " + resource + " failed. Attempt " + (maxAttempts - attemptsLeft) + " of " + maxAttempts);

                                downloadRecursive(attemptsLeft--, resource, storeProperty);
                            });
                    }
                    else {
                        console.error("Downloading resource " + resource + " failed after " + maxAttempts + "attempts");
                    }
                }

                angular.forEach($scope.spec.additionalResources, function (value, key) {
                    downloadRecursive(maxAttempts, value, key);
                });
            }

            $scope.popupEthics = function ($event) {
                $event.preventDefault();

                $scope.results.ethicsStatementViewed = true;

                baw.popUpWindow("/ParticipantInformation.html", 1000, 800);
            };

            $scope.login = function () {
                $scope.$emit('event:auth-loginRequired');
            };

            $scope.isChrome = function () {
                return Boolean(window.chrome);
            };

            $scope.verifyPreface = function verifyPreface() {
                $scope.errors.length = 0;

                if ($scope.results.consented !== true) {
                    $scope.errors.push("You must consent to participate in this experiment.");
                }

//                if ($scope.results.ethicsStatementViewed !== true) {
//                    $scope.errors.push("You must view the ethics statement before continuing (click on the link please).")
//                }

//                if ($scope.loggedIn && $scope.userData) {
//                    $scope.results.userData = angular.copy($scope.userData);
//                }
//                else {
//                    $scope.errors.push("You must be signed in to participate in this experiment, please sign in.")
//                }

                if ($scope.results.name
                    && $scope.results.name.length > 0
                    && $scope.detailsForm.fullName.$valid
                    ) {

                }
                else {
                    $scope.errors.push("You must enter your name before continuing");
                }

                if ($scope.results.email
                    && $scope.results.email.length > 0
                    && $scope.detailsForm.email.$valid
                    ) {

                }
                else {
                    $scope.errors.push("You must enter a valid email address before continuing");
                }


                if (!$scope.isChrome()) {
                    $scope.errors.push("You must be using the Google Chrome web browser to continue.");
                }

                var allDownloaded = true;
                angular.forEach($scope.spec.additionalResources, function (value, key) {
                    allDownloaded = allDownloaded && angular.isObject(value);
                });

                if (!allDownloaded) {
                    $scope.errors.push("Resources for the experiment are still downloading. Try again in a moment.");
                }

                if ($scope.errors.length > 0) {
                    return;
                }

                $scope.step = 1;
                $scope.stage = $scope.EXPERIMENT_STAGE;

            };

            $scope.getPath = function () {
                if ($scope.spec && $scope.spec.experimentSteps && $scope.spec.experimentSteps[$scope.step - 1]) {

                    return $scope.spec.experimentSteps[$scope.step - 1].template
                }
            };

            $scope.$watch(function () {
                return $scope.step;
            }, function (newValue, oldValue) {
                if (newValue > $scope.spec.experimentSteps.length) {
                    $scope.finishExperiment();
                }
            });


            $scope.prettyResults = "";
            $scope.finishExperiment = function () {

                $scope.step = 0;
                $scope.stage = $scope.FINAL_STAGE;

                // send back results to server
                $scope.resultsSending = true;
                $scope.resultsSentSuccessfully = undefined;

                // filter results
                var blackList = $scope.blackList;
                var tempResult = JSON.stringify($scope.results, function (key, value) {
                    if (key !== "" && blackList.indexOf(key) >= 0) {
                        return undefined;
                    }

                    return value;
                });

                $scope.results = JSON.parse(tempResult);

                $scope.prettyResults = JSON.stringify($scope.results, undefined, 2);

                $http.post('/experiments', $scope.results)
                    .success(function (data, status, headers, config) {

                        $scope.resultsSending = false;
                        $scope.resultsSentSuccessfully = true;
                    })
                    .error(function (data, status, headers, config) {
                        $scope.resultsSending = false;
                        $scope.resultsSentSuccessfully = false;
                    });
            };
        }]);


    app.controller('RapidScanCtrl', ['$scope', '$resource', '$routeParams', '$route', '$http', '$timeout', 'Media', 'AudioEvent', 'Tag',
        function RapidScanCtrl($scope, $resource, $routeParams, $route, $http, $timeout, Media, AudioEvent, Tag) {
            function ts() {
                return (new Date()).toISOString();
            }

            var BASE_URL = "http://sensor.mquter.qut.edu.au/Spectrogram.ashx?ID={0}&start={1}&end={2}";
            $scope.ft = baw.secondsToDurationFormat;

            $scope.bigScope = $scope.$parent.$parent;
            $scope.bigScope.blackList = $scope.bigScope.blackList || [];
            $scope.bigScope.blackList = $scope.bigScope.blackList.concat(
                [
                    'notes',
                    '$$hashKey',
                    "template",
                    "notes",
                    "extraInstructions",
                    "show",
                    "downloaded"

                ]
            );

            //$scope.bigScope.results.steps = angular.copy($scope.bigScope.spec.experimentSteps);
            $scope.bigScope.results.version = $scope.bigScope.spec.version;
            $scope.bigScope.results.yesOnly = $scope.bigScope.spec.yesOnly;


            // use the downloaded stats to configure the experiment
            // find minimum
            var minCount = null;
            angular.forEach($scope.bigScope.spec.additionalResources.experimentCombinationCounts, function (value, key) {
                var c = value.count;
                if (minCount === null || c < minCount) {
                    minCount = c;
                }
            });

            // extract minimums
            var lowestCodes = [];
            angular.forEach($scope.bigScope.spec.additionalResources.experimentCombinationCounts, function (value, key) {
                var c = value.count;
                if (c === minCount) {
                    lowestCodes.push(key);
                }
            });

            // randomly pick from the lowest group
            var lowestCode;
            if (lowestCodes.length != 1) {
                var keys;

                if (lowestCodes.length == 0) {
                    console.error("no lowest codes", lowestCodes, $scope.bigScope.spec.additionalResources);
                    throw "No lowest codes.";
                    //keys = Object.keys($scope.bigScope.spec.additionalResources.experimentCombinationCounts);
                    //keys = Object.keys($scope.bigScope.spec.additionalResources.experimentCombinationCounts);
                } else {
                    keys = lowestCodes;
                }
                var rand = Math.floor(Math.random() * keys.length);
                lowestCode = keys[rand];
            }
            else {
                lowestCode = lowestCodes[0];
            }

            if (!lowestCode || lowestCode.length !== 2) {
                throw "Experiment configuration incorrect";
            }

            // record experiment setup in results
            $scope.bigScope.results.code = lowestCode;
            var countSpec = $scope.bigScope.spec.additionalResources.experimentCombinationCounts[lowestCode];
            console.log("Experiment " + lowestCode + " chosen", countSpec);

            // now copy in the steps and configure the speeds
            $scope.bigScope.results.steps = [];
            for (var stepIndex = 0; stepIndex < countSpec.dataSets.length; stepIndex++) {
                // find the speed obj
                var dsId = countSpec.dataSets[stepIndex];
                var dataSet = angular.copy(
                    $scope.bigScope.spec.experimentSteps.filter(function (value) {
                        return value.id === dsId;
                    })[0]
                );

                // find the step obj
                var sId = countSpec.speeds[stepIndex];
                var speed = angular.copy(
                    $scope.bigScope.spec.speeds.filter(function (value) {
                        return value.speed === sId;
                    })[0]
                );

                // merge speed and step
                dataSet.speed = speed;

                // insert into results
                $scope.bigScope.results.steps.push(dataSet);
            }

            // lastly, insert training round at start
            var trainingStep = angular.copy(
                $scope.bigScope.spec.experimentSteps.filter(function (value) {
                    return value.id === "training";
                })[0]);
            $scope.bigScope.results.steps.unshift(trainingStep);

            // print order for sanity
            // also attach to results for later sanity
            var prettyString = "Experimental step order";
            var order = "";
            $scope.bigScope.results.order = [];
            angular.forEach($scope.bigScope.results.steps, function (value, index) {
                $scope.bigScope.results.order.push({index: index, speed: value.speed.speed, id: value.id});
                prettyString += String.format("\nIndex: {0}, Speed: {1}, Id: {2}", index, value.speed.speed, value.id);
                order += value.speed.speed + "\t";
            });
            console.warn(order);
            console.warn(prettyString);


            $scope.stepResults = undefined;
            var EXPERIMENT_STEPS = $scope.bigScope.results.steps.length;
            $scope.$watch(function () {
                return $scope.bigScope.step;
            }, function (newValue, oldValue) {
                if (newValue <= EXPERIMENT_STEPS) {

                    $scope.showInstructions = true;

                    $scope.stepResults = $scope.bigScope.results.steps[$scope.bigScope.step - 1];

                    $scope.stepResults.flashes = calculateFlashes();

                    // start checking for images that are downloading
                    console.info("starting download loop...");
                    downloading();
                }
            });

            $scope.totalDownloaded = 0;
            function downloading() {
                var total = 0;

                total = $(".imageList img")
                    .toArray()
                    .reduce(
                    function (prev, value, index, array) {
                        return prev + value.complete
                    },
                    0
                );

                $scope.totalDownloaded = total;

                if ($scope.stepResults.flashes.length != $scope.totalDownloaded) {
                    $timeout(downloading, 250);
                }
                else {
                    console.info("finished download.");
                }

                return total;
            }

            $scope.showInstructions = true;
            $scope.showDoneButton = false;
            $scope.start = function () {
                $scope.showInstructions = false;
                $scope.stepResults.preCountDownStartTimeStamp = ts();

                $scope.stepResults.flashes[0].show = true;
                $scope.currentFlash = 0;
                $scope.segment = $scope.stepResults.flashes[$scope.currentFlash];

                $scope.countDown = $scope.bigScope.spec.countDown;

                $scope.showDoneButton = false;

                // actually start it after countdown
                countDown();

            };

            function countDown() {
                window.setTimeout(function () {
                        $scope.$apply(function () {
                            $scope.countDown = $scope.countDown - 1;

                            if ($scope.countDown == 0) {

                                // eventually start it!
                                $scope.stepResults.startTimeStamp = ts();

                                $scope.tick();
                                focus();
                                $timeout(function () {
                                    focus();
                                })
                            }
                            else {
                                countDown();
                            }
                        });
                    },
                    1000)
            }


            $scope.lastTick = $scope.pauseTick = undefined;
            $scope.paused = false;
            $scope.pauseOrResume = function () {
                if ($scope.paused) {
                    var diff = $scope.remainingTime - ($scope.pauseTick - $scope.lastTick);

                    $scope.paused = false;

                    console.warn("resuming with delay:" + diff);
                    $scope.pauseTick = 0;
                    $scope.stepResults.flashes[$scope.currentFlash].pauses.push({state: "resumed", timeStamp: ts()});
                    // var tempTimer = $timeout(function () {
                    $scope.tick(diff);
                    //$timeout.cancel(tempTimer);
                    // }, diff);
                } else {
                    $scope.pauseTick = Date.now();

                    //window.clearTimeout($scope.timeoutId);
                    $timeout.cancel($scope.timeoutId);

                    $scope.paused = true;
                    $scope.stepResults.flashes[$scope.currentFlash].pauses.push({state: "paused", timeStamp: ts()});
                }


            };

            function focus() {
                // bad voodoo
                document.getElementById('experimentKeyPressDiv').focus();
            }

            $scope.animationControl = function () {
                return $scope.paused || $scope.countDown ? "paused" : "running";
            };


            $scope.animationText = "";
            function animationTextUpdate(enable) {
                //return 'collapseWidthLeft ' + $scope.stepResults.speed  + 's linear 0s'

                // this is how we reset the animation
                if (!enable) {
                    $scope.animationText = "";
                }

                $scope.animationText = $scope.stepResults.speed.speed + 's linear 0s infinite'
            }

            $scope.tick = function (delay) {
                focus();

//                // need to record ticks, except for when resuming...
//                // because tick technically only happen when it 'ticks'
//                // and not when resuming from pause
//                if (!delay) {
//
//                }

                var actualDelay = delay === undefined ? $scope.stepResults.speed.speed * 1000 : delay;
                $scope.remainingTime = actualDelay;
                $scope.lastTick = Date.now();

                animationTextUpdate(true);

                //$scope.timeoutId = window.setTimeout(function () {
                $scope.timeoutId = $timeout(function () {
                        if ($scope.paused) {
                            // exit early to disable timer
                            //window.clearTimeout($scope.timeoutId);
                            $timeout.cancel($scope.timeoutId);
                            return;
                        }

                        // this seems like a huge waste... but I can't figure out how to do this better
//                        $scope.$apply(function() {
//                           animationTextUpdate(false);
//                        });

                        //$scope.$apply(function () {
                        animationTextUpdate(true);

                        // hide the old image
                        $scope.stepResults.flashes[$scope.currentFlash].show = false;

                        // increment the flashcard
                        $scope.currentFlash++;
                        // bind a new data object
                        $scope.segment = $scope.stepResults.flashes[$scope.currentFlash];

                        focus();


                        if ($scope.currentFlash >= $scope.stepResults.flashes.length) {
                            $scope.stepResults.endFlashesTimeStamp = (new Date()).toISOString();
                            $scope.showDoneButton = true;
                            return;
                        }

                        //$scope.lastTick = Date.now();

                        $scope.stepResults.flashes[$scope.currentFlash].show = true;

                        $scope.tick();
                        //});
                    },
                    actualDelay
                );
            };

            $scope.hit = function ($event) {
                var positiveHit = undefined;
                switch ($event.keyCode) {
                    // ctrl
                    case 17:
                        positiveHit = $event.originalEvent.keyLocation == 2;
                        break;
                    // shift
                    case 16:
                        positiveHit = $event.originalEvent.keyLocation == 2;
                        break;
                    // right arrow key
                    case 39:
                        positiveHit = true;
                        break;
                    // left arrow key
                    case 37:
                        positiveHit = false;
                        break;
                }

                if (positiveHit == undefined || $scope.currentFlash >= $scope.stepResults.flashes.length) {
                    console.warn("ignored hit");
                    return;
                }
                var result = positiveHit ? "positive" : "negative";
                console.log("HIT!" /*, $event*/, result);

                var f = $scope.stepResults.flashes[$scope.currentFlash];
                f.detected = result;
                f.hits.push({hit: result, timeStamp: ts()});
            };


            $scope.end = function () {
                $scope.stepResults.endTimeStamp = ts();
                window.clearTimeout($scope.timeoutId);
                $scope.bigScope.step = $scope.bigScope.step + 1;

            };

            $scope.SPECTROGRAM_WIDTH = 1080;
            var PPS = 45;
            //$scope.stepResults.flashes = [];

            function calculateFlashes() {

                // work out the scale of flash cards that need to be shown
                var adjustedPPS = PPS * $scope.stepResults.compression,
                    segmentDuration = $scope.SPECTROGRAM_WIDTH / adjustedPPS;

                var segments = [];
                for (var segmentIndex = 0; segmentIndex < $scope.stepResults.segments.length; segmentIndex++) {
                    var segment = $scope.stepResults.segments[segmentIndex];
                    var durationSeconds = segment.endTime - segment.startTime;

                    var numberOfSegments = durationSeconds / segmentDuration;

                    for (var i = 0; i < numberOfSegments; i++) {
                        var start = segment.startTime + (i * segmentDuration),
                            end = start + segmentDuration;

                        var imageUrl = String.format(BASE_URL, segment.audioId, start * 1000, end * 1000);

                        segments.push({
                                start: start,
                                end: end,
                                audioId: segment.audioId,
                                imageLink: imageUrl,
                                show: false,
                                detected: null,
                                hits: [],
                                pauses: [],
                                downloaded: null
                            }
                        );
                    }
                }

                if ($scope.stepResults.randomiseOrder) {
                    baw.shuffle(segments);
                }

                // give flash cards index, so we always know order
                // has to happen after shuffle
                for (var counter = 0; counter < segments.length; counter++) {
                    segments[counter].index = counter;
                }

                return segments;
            }
        }]);

    app.controller('VirtualBirdTourCtrl', ['$scope', '$resource', '$routeParams', '$route', '$http', 'Media', 'AudioEvent', 'Tag',
        function VirtualBirdTourCtrl($scope, $resource, $routeParams, $route, $http, Media, AudioEvent, Tag) {

            /*
             * TO DO:
             *  - choose more species
             *  - load annotation YES, No, Unsure counts
             *  - survey?
             */

            /*
             "A12B12C12": {
             "count": 0,
             "items": [
             { "location":"A", "species": [1, 2] },
             { "location":"B", "species": [1, 2] },
             { "location":"C", "species": [1, 2] }
             ]
             }
             */

            //================
            // initialisation
            //=================

            var BASE_SPECTROGRAM_URL = "http://sensor.mquter.qut.edu.au/Spectrogram.ashx?ID={0}&start={1}&end={2}";
            var BASE_LOCAL_SPECTROGRAM_URL = "/experiment_assets/bird_tour/media/{0}_{1}_{2}.jpg";

            var BASE_EXTERNAL_AUDIO_URL = "http://sensor.mquter.qut.edu.au/AudioReading.ashx?ID={0}&Type={3}&start={1}&end={2}";
            var BASE_LOCAL_AUDIO_URL = "/experiment_assets/bird_tour/media/{0}_{1}_{2}.{3}"; // for webm

            var STEP_TYPE_TRANSITION = 'transition';
            var STEP_TYPE_ACTIVITY = 'activity';


            var ANNOTATION_TYPE_TO_VERIFY = 'toVerify';
            var ANNOTATION_TYPE_EXAMPLE = 'example';

            var BASE_ZOOM = 6;
            var CURRENT_LOCATION_ZOOM = 4;

            var PPMS = 0.04306640625;
            var SPECTROGRAM_PADDING_MS = Math.floor(20 / PPMS);

            $scope.bigScope = $scope.$parent.$parent;
            $scope.bigScope.blackList = $scope.bigScope.blackList || [];
            $scope.bigScope.blackList = $scope.bigScope.blackList.concat(
                [
                    'faunaImage',
                    'faunaImageAttribution',
                    'faunaImageAttributionLink',
                    'distributionImage',
                    'distributionImageAttribution',
                    'distributionImageAttributionLink',
                    'description',
                    'descriptionAttribution',
                    'descriptionAttributionLink',
                    "locationDescription",
                    "locationDescriptionAttribution",
                    "locationDescriptionAttributionLink",
                    "backgroundImageName",
                    "backgroundImageAttribution",
                    "backgroundImageAttributionLink",
                    "environmentType",
                    "environmentDescription",
                    "environmentDescriptionAttribution",
                    "environmentDescriptionAttributionLink"
                ]
            );

            $scope.bigScope.results.version = $scope.bigScope.spec.version;

            $scope.locations = angular.copy($scope.bigScope.spec.locations);
            $scope.species = angular.copy($scope.bigScope.spec.species);
            $scope.annotations = angular.copy($scope.bigScope.spec.annotations);

            $scope.transitionMarkers = [];
            $scope.currentStepResults = undefined;
            $scope.transitionMapInfoWindow = new google.maps.InfoWindow({maxWidth: 600});
            $scope.doneButtonClicked = false;
            $scope.doneButtonClicked = false;

            //================
            // define functions for template
            //=================

            $scope.userHasMadeSelectionForAllVerifyAnnotations = function () {
                if ($scope.doneButtonClicked === true || !$scope.currentStepResults || !$scope.currentStepResults.responses) {
                    // hide done button
                    return false;
                }


                var responsesCount = 0;
                for (var key in $scope.currentStepResults.responses) {
                    if ($scope.currentStepResults.responses.hasOwnProperty(key) && key.indexOf('response') !== -1) {
                        responsesCount += 1;
                    }
                }

                var totalToVerify = $scope.currentVerify.length;
                return responsesCount == totalToVerify;
            };

            $scope.verifyDone = function () {
                $scope.addAction(null, 'done', 'button click');
                $scope.doneButtonClicked = true;
            };

            $scope.nextStep = function () {
                $scope.addAction(null, 'next', 'button click');
                $scope.bigScope.step = $scope.bigScope.step + 1;

                // remove background image
                if ($scope.bigScope.results.steps.length === $scope.bigScope.step) {
                    angular.element(document.getElementById('page-wrapper')).css("background-image", '');
                }
            };

            $scope.responseSelected = function (annotationId, response) {
                $scope.addAction(annotationId, response, 'response selected');
            };

            $scope.playAudio = function (audioElementId) {
                $scope.addAction(audioElementId, 'play', 'played audio');

                var audioElement = document.getElementById(audioElementId);
                if (audioElement) {
                    audioElement.currentTime = 0;
                    audioElement.play();


                }
            };

            $scope.showTransitionMap = function () {
                return $scope.currentStepResults && $scope.currentStepResults.stepType == STEP_TYPE_TRANSITION;
            };

            $scope.firstStop = function () {
                return $scope.step === 1;
            };

            $scope.middleStops = function () {
                return $scope.step > 1 && $scope.step < $scope.results.steps.length;
            };

            $scope.moveToWaypoint = function () {
                $scope.addAction(null, 'move to waypoint', 'button click');

                $scope.moveAndShowToLocation();
            };

            $scope.ensureMapDisplayedCorrectlyNoTimeout = function (map, latLng, zoom, marker, markerContent, markerTitle) {

                google.maps.event.trigger(map, 'resize');

                map.panTo(latLng);
                map.setZoom(zoom);


                if (marker && markerContent) {
                    $scope.showMarkerInfo(map, marker, markerContent);
                }

                if (marker && markerTitle) {
                    marker.setPosition(latLng);
                    marker.setTitle(markerTitle);
                }
            };

            $scope.ensureMapDisplayedCorrectly = function (map, latLng, zoom, marker, markerContent, markerTitle) {
                var timeoutId = setTimeout(function () {
                    $scope.$safeApply2(function () {
                        $scope.ensureMapDisplayedCorrectlyNoTimeout(map, latLng, zoom, marker, markerContent, markerTitle);
                    });
                }, 600);
            };

            //================
            // define functions for controller
            //=================

            $scope.getTimestamp = function () {
                return (new Date()).toISOString();
            };

            $scope.getLowestCountItem = function (containingObject) {
                console.log('called $scope.getLowestCountItem', containingObject);
                // find minimum
                var minCount = null;
                angular.forEach(containingObject, function (value, key) {
                    var c = value.count;
                    if (minCount === null || c < minCount) {
                        minCount = c;
                    }
                });

                // extract minimums
                var lowestCodes = [];
                angular.forEach(containingObject, function (value, key) {
                    var c = value.count;
                    if (c === minCount) {
                        lowestCodes.push(key);
                    }
                });

                // randomly pick from the lowest group
                var lowestCode;
                if (lowestCodes.length != 1) {
                    var keys;

                    if (lowestCodes.length == 0) {
                        console.error("[Bird Tour Experiment] no lowest codes", lowestCodes, containingObject);
                        throw "No lowest codes..";
                    } else {
                        keys = lowestCodes;
                    }
                    var rand = Math.floor(Math.random() * keys.length);
                    lowestCode = keys[rand];
                }
                else {
                    lowestCode = lowestCodes[0];
                }

                if (!lowestCode) {
                    console.error("[Bird Tour Experiment] configuration incorrect", lowestCode, containingObject);
                    throw "Experiment configuration incorrect";
                }

                return lowestCode;
            };

            $scope.getExamplesForSpecies = function (speciesCommonName) {
                var annotations = angular.copy($scope.annotations.filter(function (element, index, array) {
                    return element.type == ANNOTATION_TYPE_EXAMPLE && element.speciesCommonName == speciesCommonName;
                }));

                $scope.addMediaUrlsToAnnotations(annotations);

                // random order
                baw.shuffle(annotations);

                // record order
                for (var exampleAnnotationIndex = 0; exampleAnnotationIndex < annotations.length; exampleAnnotationIndex++) {
                    $scope.currentStepResults.annotationExampleOrder.push({
                        'index': exampleAnnotationIndex,
                        'step': $scope.step,
                        'annotationId': annotations[exampleAnnotationIndex].id,
                        'locationName': $scope.currentLocation.name,
                        'speciesCommonName': $scope.currentSpecies.commonName
                    });
                }

                return annotations;
            };

            $scope.getItemToVerifyForSpecies = function (speciesCommonName) {

                var speciesInfo = $scope.getSpeciesInfo(speciesCommonName);

                var annotations = angular.copy($scope.annotations.filter(function (element, index, array) {
                    return element.type == ANNOTATION_TYPE_TO_VERIFY && speciesInfo.annotationIds.indexOf(element.id) !== -1;
                }));

                $scope.addMediaUrlsToAnnotations(annotations);

                $scope.addResponseCounts(annotations);

                // random order
                baw.shuffle(annotations);

                // record order
                for (var verifyAnnotationIndex = 0; verifyAnnotationIndex < annotations.length; verifyAnnotationIndex++) {
                    $scope.currentStepResults.annotationVerifyOrder.push({
                        'index': verifyAnnotationIndex,
                        'step': $scope.step,
                        'annotationId': annotations[verifyAnnotationIndex].id,
                        'locationName': $scope.currentLocation.name,
                        'speciesCommonName': $scope.currentSpecies.commonName
                    });
                }

                return annotations;
            };

            $scope.addMediaUrlsToAnnotations = function (annotations) {
                angular.forEach(annotations, function (value, key) {

                    var startMs = Math.max(0, value.offsetStart - SPECTROGRAM_PADDING_MS);
                    var endMs = value.offsetEnd + SPECTROGRAM_PADDING_MS;

                    value.spectrogramImage = String.format(BASE_LOCAL_SPECTROGRAM_URL, value.audioId, startMs, endMs);
                    value.audioWebm = String.format(BASE_LOCAL_AUDIO_URL, value.audioId, startMs, endMs, 'webm');
                    value.audioOga = String.format(BASE_EXTERNAL_AUDIO_URL, value.audioId, startMs, endMs, 'ogg');
                    value.audioMp3 = String.format(BASE_EXTERNAL_AUDIO_URL, value.audioId, startMs, endMs, 'mp3');
                });
            };

            $scope.addResponseCounts = function (annotations) {
                angular.forEach(annotations, function (value, key) {

                    var response_counts = $scope.annotationResponseCounts[value.id];

                    if (response_counts) {
                        value.otherPeopleTotal = response_counts.total ? response_counts.total : 0;
                        value.otherPeopleYes = response_counts.yes ? response_counts.yes : 0;
                        value.otherPeopleNo = response_counts.no ? response_counts.no : 0;
                        value.otherPeopleUnsure = response_counts.unsure ? response_counts.unsure : 0;

                        //console.log(value);
                    } else {
                        value.otherPeopleTotal = 0;
                        value.otherPeopleYes = 0;
                        value.otherPeopleNo = 0;
                        value.otherPeopleUnsure = 0;
                    }


                    if (value.otherPeopleTotal > 0) {
                        // now calculate percentages
                        value.otherPeopleYes = value.otherPeopleYes / value.otherPeopleTotal * 100;
                        value.otherPeopleNo = value.otherPeopleNo / value.otherPeopleTotal * 100;
                        value.otherPeopleUnsure = value.otherPeopleUnsure / value.otherPeopleTotal * 100;
                    }


                    /*


                     $scope.annotationResponseCounts
                     */


                    /*
                     .filter(function (element, index, array) {
                     return element.type == ANNOTATION_TYPE_EXAMPLE && element.speciesCommonName == speciesCommonName;
                     }
                     $scope.annotatonResponseCounts['id']
                     */
                });
            };

            $scope.addMarkerClick = function (map, marker, content) {
                google.maps.event.addListener(marker, 'click', function () {
                    $scope.showMarkerInfo(map, marker, content);
                });
            };

            $scope.showMarkerInfo = function (map, marker, content) {
                $scope.transitionMapInfoWindow.setContent(content);
                $scope.transitionMapInfoWindow.open(map, marker);
            };

            $scope.getLocation = function (name) {
                var found = $scope.locations.filter(function (element, index, array) {
                    return (element.name == name);
                });
                if (found.length == 1) {
                    return found[0];
                }
                return null;
            };

            $scope.getMapForLocation = function (locationName, zoom) {
                var locationInfo = $scope.getLocation(locationName);
                if (locationInfo) {
                    //var locationEncoded = baw.angularCopies.encodeUriQuery(locationInfo.name, true);
                    var markerEncoded = baw.angularCopies.encodeUriQuery("color:0x7a903c|label:W|" + locationInfo.lat + "," + locationInfo.long, true);
                    var styleEncoded1 = baw.angularCopies.encodeUriQuery("style=feature:administrative", true);
                    var styleEncoded2 = baw.angularCopies.encodeUriQuery("style=feature:landscape.natural", true);
                    var styleEncoded3 = baw.angularCopies.encodeUriQuery("style=feature:water", true);
                    return "https://maps.googleapis.com/maps/api/staticmap?sensor=false&size=200x200&maptype=hybrid&markers=" + markerEncoded +
                        "&zoom=" + zoom;
                }
                return null;
            };

            $scope.getSpeciesInfo = function (speciesCommonName) {
                var found = $scope.species.filter(function (element, index, array) {
                    return (element.commonName == speciesCommonName);
                });
                if (found.length == 1) {
                    return found[0];
                }
                return null;
            };

            $scope.getLocationByOrderIdentifier = function (orderId) {
                var found = $scope.locations.filter(function (element, index, array) {
                    return (element.locationOrderIdentifier == orderId);
                });
                if (found.length == 1) {
                    return found[0];
                }
                return null;
            };


            $scope.addAction = function (elementId, action, type) {
                var actionObject = {
                    "elementId": elementId,
                    "action": action,
                    "type": type,
                    "timestamp": $scope.getTimestamp()
                };
                $scope.currentStepResults.actions.push(actionObject);
            };


            $scope.getTransitionMarkerDetails = function (locationName) {
                var found = $scope.transitionMarkers.filter(function (element, index, array) {
                    return (element.locationName == locationName);
                });
                if (found.length == 1) {
                    return found[0];
                }
                return null;
            };

            $scope.createMap = function (elementId, LatLng, zoom) {
                return new google.maps.Map(
                    document.getElementById(elementId),
                    {
                        center: LatLng,
                        zoom: zoom,
                        mapTypeId: google.maps.MapTypeId.HYBRID
                    }
                );
            };

            $scope.createMarker = function (map, LatLng, title) {
                return new google.maps.Marker({
                    position: LatLng,
                    map: map,
                    title: title
                });
            };

            $scope.moveAndShowFromLocation = function () {
                if ($scope.currentStepResults.stepType == STEP_TYPE_TRANSITION) {
                    $scope.showContinueButton = false;

                    var fromLocation = null;
                    var fromLatLng = null;
                    var fromDetails = null;

                    if ($scope.currentStepResults.fromLocation) {
                        // show the from location info window, pan to the location
                        fromLocation = $scope.getLocation($scope.currentStepResults.fromLocation.name);
                        fromLatLng = new google.maps.LatLng(fromLocation.lat, fromLocation.long);

                        fromDetails = $scope.getTransitionMarkerDetails($scope.currentStepResults.fromLocation.name);


                        $scope.ensureMapDisplayedCorrectly($scope.transitionMap, fromLatLng, BASE_ZOOM,
                            fromDetails.marker, fromDetails.content);

                    } else {
                        // first waypoint, start at middle of australia
                        $scope.transitionMap.panTo(new google.maps.LatLng(-24.287027, 134.208984));
                        $scope.transitionMap.setZoom(4);
                    }
                }
            };

            $scope.moveAndShowToLocation = function () {
                if ($scope.currentStepResults.stepType == STEP_TYPE_TRANSITION) {
                    $scope.showContinueButton = false;

                    var toLocation = null;
                    var toLatLng = null;
                    var toDetails = null;

                    if ($scope.currentStepResults.toLocation) {

                        // show the from location info window, pan to the location
                        toLocation = $scope.getLocation($scope.currentStepResults.toLocation.name);
                        toLatLng = new google.maps.LatLng(toLocation.lat, toLocation.long);

                        toDetails = $scope.getTransitionMarkerDetails($scope.currentStepResults.toLocation.name);

                        // change the background image
                        angular.element(document.getElementById('page-wrapper'))
                            .css("background-image", "url('" + $scope.getImagePath(toLocation.backgroundImageName) + "')");

                        $scope.ensureMapDisplayedCorrectly($scope.transitionMap, toLatLng, BASE_ZOOM,
                            toDetails.marker, toDetails.content);

                        $scope.showContinueButton = true;
                    }
                }
            };

            $scope.getImagePath = function (imageFileName) {
                return '/experiment_assets/bird_tour/' + imageFileName;
            };


            //================
            // perform set up
            //=================

            // create and init maps and markers
            var middleAustralia = new google.maps.LatLng(-24.287027, 134.208984);
            $scope.transitionMap = $scope.createMap('transitionMap', middleAustralia, CURRENT_LOCATION_ZOOM);
            $scope.locationMap = $scope.createMap('locationMap', middleAustralia, CURRENT_LOCATION_ZOOM);
            $scope.locationMarker = $scope.createMarker($scope.locationMap, middleAustralia, $scope.currentLocationName);

            // get order for locations and species
            // use the downloaded stats to configure the experiment
            // find least-used location order
            var locationOrderId = $scope.getLowestCountItem($scope.bigScope.spec.additionalResources.experimentCombinationCounts.locations);

            var locationSpeciesOrder = {};
            locationSpeciesOrder.locations = $scope.bigScope.spec.additionalResources.experimentCombinationCounts.locations[locationOrderId].locations;
            locationSpeciesOrder.species = {};

            angular.forEach($scope.bigScope.spec.additionalResources.experimentCombinationCounts.species, function (value, key) {
                var currentSpeciesInfo = $scope.bigScope.spec.additionalResources.experimentCombinationCounts.species[key];
                var speciesOrderLowestCountId = $scope.getLowestCountItem(value);
                locationSpeciesOrder.species[key] = currentSpeciesInfo[speciesOrderLowestCountId].species;
            });

            $scope.bigScope.results.locationSpeciesOrder = locationSpeciesOrder;
            console.log('[Bird Tour Experiment] location and species order.', JSON.stringify(locationSpeciesOrder, undefined, 4));

            // store annotation response counts
            $scope.annotationResponseCounts = $scope.bigScope.spec.additionalResources.annotationsResponseCounts;

            //================
            // now copy in the steps and configure the locations and species
            //=================

            $scope.bigScope.results.steps = [];

            for (var locationStepIndex = 0; locationStepIndex < locationSpeciesOrder.locations.length; locationStepIndex++) {

                var locationId = locationSpeciesOrder.locations[locationStepIndex];

                // add the transition step
                var transitionStep = angular.copy(
                    $scope.bigScope.spec.experimentSteps.filter(function (value) {
                        return value.stepType === STEP_TYPE_TRANSITION;
                    })[0]
                );
                if (locationStepIndex == 0) {
                    transitionStep.fromLocation = null;
                } else {
                    var prevLocationId = locationSpeciesOrder.locations[locationStepIndex - 1];
                    transitionStep.fromLocation = $scope.getLocationByOrderIdentifier(prevLocationId);
                }

                // always add the to location
                transitionStep.toLocation = $scope.getLocationByOrderIdentifier(locationId);

                $scope.bigScope.results.steps.push(transitionStep);

                if (transitionStep.toLocation) {
                    // add the species steps
                    for (var speciesStepIndex = 0; speciesStepIndex < locationSpeciesOrder.species[locationId].length; speciesStepIndex++) {

                        var speciesOrderId = locationSpeciesOrder.species[locationId][speciesStepIndex];

                        var speciesStep = angular.copy(
                            $scope.bigScope.spec.experimentSteps.filter(function (value) {
                                return value.stepType === STEP_TYPE_ACTIVITY;
                            })[0]
                        );
                        speciesStep.location = transitionStep.toLocation;
                        speciesStep.species = angular.copy($scope.bigScope.spec.species.filter(function (value) {
                            return value.locationName === speciesStep.location.name && value.locationSpeciesOrderIdentifier == speciesOrderId;
                        })[0]);

                        $scope.bigScope.results.steps.push(speciesStep);
                    }
                }

                // if this is the last location, add an additional transition at the end
                if (locationStepIndex == locationSpeciesOrder.locations.length - 1) {
                    var lastStep = angular.copy(transitionStep);
                    lastStep.fromLocation = angular.copy(lastStep.toLocation);
                    lastStep.toLocation = null;
                    $scope.bigScope.results.steps.push(lastStep);
                }
            }

            // print order for sanity
            $scope.bigScope.results.order = [];
            angular.forEach($scope.bigScope.results.steps, function (value, index) {

                if (value.stepType == STEP_TYPE_TRANSITION) {
                    $scope.bigScope.results.order.push({
                        index: index,
                        fromLocationName: value.fromLocation ? value.fromLocation.name : null,
                        fromLocationOrderIdentifier: value.fromLocation ? value.fromLocation.locationOrderIdentifier : null,
                        toLocationName: value.toLocation ? value.toLocation.name : null,
                        toLocationOrderIdentifier: value.toLocation ? value.toLocation.locationOrderIdentifier : null
                    });
                } else if (value.stepType == STEP_TYPE_ACTIVITY) {
                    $scope.bigScope.results.order.push({
                        index: index,
                        locationName: value.location ? value.location.name : null,
                        locationOrderIdentifier: value.location ? value.location.locationOrderIdentifier : null,
                        speciesCommonName: value.species ? value.species.commonName : null,
                        locationSpeciesOrderIdentifier: value.species ? value.species.locationSpeciesOrderIdentifier : null
                    });
                } else {
                    console.error("[Bird Tour Experiment] Invalid value for stepType.", value);
                }
            });
            console.info('[Bird Tour Experiment] step order', JSON.stringify($scope.bigScope.results.order, undefined, 4));

            var stepCount = $scope.bigScope.results.steps.length;

            $scope.$watch(function () {
                return $scope.bigScope.step;
            }, function (newValue, oldValue) {
                if (newValue <= stepCount) {
                    $scope.currentStepResults = $scope.bigScope.results.steps[$scope.bigScope.step - 1];

                    $scope.currentStepResults.actions = [];
                    $scope.currentStepResults.annotationExampleOrder = [];
                    $scope.currentStepResults.annotationVerifyOrder = [];

                    if ($scope.currentStepResults.stepType == STEP_TYPE_ACTIVITY) {
                        // show the species information and annotation verification activity.

                        $scope.currentStepResults.startTimestamp = $scope.getTimestamp();
                        $scope.currentStepResults.responses = {};

                        $scope.currentLocation = $scope.getLocation($scope.currentStepResults.location.name);
                        $scope.currentSpecies = $scope.getSpeciesInfo($scope.currentStepResults.species.commonName);

                        $scope.currentLocationName = $scope.currentLocation.name + " (" + $scope.currentLocation.environmentType + ")";

                        $scope.currentExamples = $scope.getExamplesForSpecies($scope.currentSpecies.commonName);
                        $scope.currentVerify = $scope.getItemToVerifyForSpecies($scope.currentSpecies.commonName);

                        // change the map
                        var theNewLocation = new google.maps.LatLng($scope.currentLocation.lat, $scope.currentLocation.long);

                        $scope.ensureMapDisplayedCorrectly($scope.locationMap, theNewLocation, CURRENT_LOCATION_ZOOM,
                            $scope.locationMarker, null, $scope.currentLocationName);

                        // user has clicked on Done button
                        $scope.doneButtonClicked = false;
                    } else if ($scope.currentStepResults.stepType == STEP_TYPE_TRANSITION) {

                        $scope.moveAndShowFromLocation();

                    }
                }
            });


            //================
            // create and store all locations for transition map
            //=================

            var MAP_LINE_COLOUR = 'yellow';
            var MAP_POINT_COLOUR = '#FF7F50';
            var MAP_LINE_ARROW_COLOUR = '#FFA500';

            // get array of steps that are transitions, then create all markers and arrows
            var transitionLocations = $scope.bigScope.results.steps.filter(function (element, index, array) {
                return (element.stepType == STEP_TYPE_TRANSITION && element.toLocation);
            });

            for (var orderedLocationIndex = 0; transitionLocations.length > orderedLocationIndex; orderedLocationIndex++) {
                var currentOrderedLocation = transitionLocations[orderedLocationIndex];

                var fromLocation = null;
                var fromLatLng = null;

                var toLocation = null;
                var toLatLng = null;

                if (currentOrderedLocation.toLocation) {
                    toLocation = $scope.getLocation(currentOrderedLocation.toLocation.name);
                    toLatLng = new google.maps.LatLng(toLocation.lat, toLocation.long);
                    var toContent = String.format(
                        '<div><h1>{0}</h1>' +
                            '<div style="float:right;margin:00 5px 5px;"><img style="width:200px;" src="{8}"><a href="{10}" class="mapAttribution" target="_blank">Source: {9}</a></div>' +
                            '<p>{1}</p>' +
                            '<a href="{3}" class="mapAttribution" target="_blank">Source: {2}</a>' +
                            '<h2>{4}</h2>' +
                            '<p>{5}</p>' +
                            '<a href="{7}" class="mapAttribution"  target="_blank">Source: {6}</a>' +
                            '</div>',
                        toLocation.name,
                        toLocation.locationDescription, toLocation.locationDescriptionAttribution, toLocation.locationDescriptionAttributionLink,
                        toLocation.environmentType,
                        toLocation.environmentDescription, toLocation.environmentDescriptionAttribution, toLocation.environmentDescriptionAttributionLink,
                        $scope.getImagePath(toLocation.backgroundImageName),
                        toLocation.backgroundImageAttribution, toLocation.backgroundImageAttributionLink
                    );
                    var toMarker = new google.maps.Marker({
                        position: toLatLng,
                        map: $scope.transitionMap,
                        title: toLocation.name,
                        icon: {
                            path: google.maps.SymbolPath.CIRCLE,
                            scale: 5,
                            fillOpacity: 1,
                            //fillColor: MAP_POINT_COLOUR,
                            strokeColor: MAP_POINT_COLOUR
                        }
                    });

                    $scope.addMarkerClick($scope.transitionMap, toMarker, toContent);
                    $scope.transitionMarkers.push({locationName: currentOrderedLocation.toLocation.name, latLng: toLatLng, marker: toMarker, content: toContent});

                }

                if (currentOrderedLocation.fromLocation && currentOrderedLocation.toLocation) {
                    fromLocation = $scope.getLocation(currentOrderedLocation.fromLocation.name);
                    fromLatLng = new google.maps.LatLng(fromLocation.lat, fromLocation.long);

                    toLocation = $scope.getLocation(currentOrderedLocation.toLocation.name);
                    toLatLng = new google.maps.LatLng(toLocation.lat, toLocation.long);

                    var arrowSymbol = {
                        path: google.maps.SymbolPath.FORWARD_CLOSED_ARROW,
                        strokeColor: MAP_LINE_ARROW_COLOUR,
                        fillColor: MAP_LINE_ARROW_COLOUR,
                        fillOpacity: 1,
                        strokeOpacity: 1,
                        strokeWeight: 1
                    };

                    var lineSymbol = {
                        path: 'M 0,-1 0,1',
                        strokeColor: MAP_LINE_COLOUR,
                        strokeOpacity: 1,
                        strokeWeight: 2
                    };

                    var line = new google.maps.Polyline({
                        path: [fromLatLng, toLatLng],
                        strokeOpacity: 0,
                        icons: [
                            {
                                icon: lineSymbol,
                                offset: '0',
                                repeat: '20px'
                            },
                            {
                                icon: arrowSymbol,
                                offset: '50%'
                            },
                            {
                                icon: arrowSymbol,
                                offset: '95%'
                            }
                        ],
                        map: $scope.transitionMap
                    });
                }

            }


        }]);
})();