Source: piwakawaka.js

/********************************************************************
* Copyright (c) 2018, Douglas Campbell
* Licensed under the MIT License
********************************************************************/

/**
* OpenSeadragon viewer div HTML ID.
* @constant
* @type {String}
* @default
*/
const piwakawakaViewerId = "image-navigator-viewer";

/**
* Label overlay HTML ID used for the generated label div.
* @constant
* @type {String}
* @default
*/
const piwakawakaLabelOverlayId = "image-navigator-label-div";

/**
* Label overlay CSS class. This CSS classname is used to style the labels.
* @constant
* @type {String}
* @default
*/
const piwakawakaLabelOverlayClass = "image-navigator-label";

/**
__Pīwakawaka IIIF Image Navigator__

A controlled experience of an IIIF image in an OpenSeadragon (OSD) viewer.

The Pīwakawaka library adds a wrapper around an
[OpenSeadragon](https://openseadragon.github.io/) viewer object
(designed to present zoomable [IIIF images](http://iiif.io/)).
It takes over control of OpenSeadragon, presenting selected points of interest
(using pan, zoom, pauses, and labels) in a configurable sequence.

Source: [GitHub repository](https://github.com/staplegun/piwakawaka)

Requires:
- [OpenSeadragon](https://openseadragon.github.io/) -
`<script src="https://cdnjs.cloudflare.com/ajax/libs/openseadragon/2.3.1/openseadragon.min.js"></script>`
- [NoSleep.js](https://github.com/richtr/NoSleep.js) -
`<script src="https://cdn.rawgit.com/richtr/NoSleep.js/v0.7.1/dist/NoSleep.js"></script>`

_Implementation notes_

OpenSeadragon uses three image co-ordinate systems:
1. image = pixels of the source image (as at original/100% resolution)
e.g. width: 0-4000, height: 0-5000
2. viewport = percentage of the source image
e.g. width: 0 - 1, height: 0 - 1
3. web container = pixels of the HTML element showing a part of the source image
e.g. width: 0 - 300, height: 0 - 400

Most calculations here use source image co-ordinates (1 above), which are
converted to/from viewport or web co-ordinates as necessary for OSD operations.
While this does increase the number of calculations/conversions, it makes it
easier to define locations to show, e.g. it is easier to specify a region of
an image when looking at the original image in image editor software.

Regions are specified as rectangles, following OSD's
[Rect](https://openseadragon.github.io/docs/OpenSeadragon.Rect.html) structure
(excluding rotation) as `{x,y,w,h}`:
- `x` = horizontal position of the top-left corner of the region rectangle,
where 0 is the left edge of the source image
- `y` = vertical position of the top-left corner of the region rectangle,
where 0 is the top edge of the source image
- `w` = horizontal width of the rectangle (to the right)
- `h` = vertical height of the rectangle (downwards)

For example, `{20,40,300,500}` defines a rectangle that starts 20 pixels from
the left edge of the source image and 40 pixels from its top,
then extends 300 pixels to the right and 500 pixels downwards.

_Pīwakawaka name_

Named after the Pīwakawaka (_pee-wahkah-wahkah_) bird
([New Zealand Fantail](https://en.wikipedia.org/wiki/New_Zealand_fantail))
which typically flits about constantly from place to place looking for tasty morsels.
*/
class Piwakawaka {

  /**
  * Constructs a Pīwakawaka image navigator wrapper around an OpenSeadragon viewer.
  *
  * @param {Object} - Configuration JSON
  * @param {Function} - Callback function to call when play begins/resumes
  * @param {Function} - Callback function to call when play stops
  */
  constructor(config, playCallback, stopCallback) {
    this._playCallback = playCallback
    this._stopCallback = stopCallback
    // set defaults
    this._fillViewer = Piwakawaka.defaultFillViewer
    this._speedFactor = Piwakawaka.defaultSpeedFactor
    this._springStiffness = Piwakawaka.defaultSpringStiffness
    this._fullscreenViewer = true
    this._sequenceStep = 0
    this._paused = true
    this._conditions = void 0
    this.initConditions()
    // create viewer element in HTML DOM
    this._viewerDiv = document.createElement("div");
    this._viewerDiv.setAttribute("id", piwakawakaViewerId);
    this._viewerDiv.style.visibility = "hidden"
    document.body.appendChild(this._viewerDiv);
    // prepare label overlay in HTML DOM
    this._overlayElement = document.createElement("div");
    this._overlayElement.id = piwakawakaLabelOverlayId
    this._overlayElement.className = piwakawakaLabelOverlayClass
    // create OSD viewer
    this.init(config)
    this._viewer = void 0
    this.initViewer()
    // create NoSleep
    this._noSleep = new NoSleep();
  }

  /**
  * Stores the supplied configuration. Only stores non-empty
  * supplied properties (other existing properties are unchanged).
  *
  * @param {Object} - Configuration JSON
  */
  init(config) {
    console.log("Init Pīwakawaka navigator object",config)
    if (config === undefined || config == null) return
    if (config.image != null) this.setImageLocation(config.image)
    if (config.speed != null) this.setSpeedFactor(config.speed)
    if (config.fillViewer != null) this.setFillViewer(config.fillViewer)
    if (config.crop != null) this.setCrop(config.crop[0], config.crop[1], config.crop[2], config.crop[3])
    if (config.sequence != null) this.setSequence(config.sequence)
    this._previousConfig = this._config  // useful?
    this._config = config
    this._sequenceStep = 0
  }

  /**
  * Resets all conditions that trigger whether actions should be skipped.
  *
  * Conditions:
  * - `overEdge` = 'prev' or 'next' attempt would move beyond the edge of the image
  */
  initConditions() {
    this._conditions = {"overEdge": false}
  }

  /**
  * Sets the IIIF image URL in the config.
  *
  * @param {String}
  */
  setImageLocation(imageUrl) {
    if (imageUrl == undefined || imageUrl == null) return
    this._imageLocation = imageUrl
  }

  /**
  * Sets the supplied Pīwakawaka navigation sequence in the config.
  *
  * @param {Object}
  */
  setSequence(sequence) {
    if ($.type(sequence) != 'array') console.log("ERROR: sequence is not an array")
    this._sequence = sequence
  }

  /**
  * Sets a cropped rectangle area within the image in the config,
  * so auto-pan operations stay within the cropped box.
  * All parameters are mandatory. If any are null cropping is reset/removed.
  *
  * @param {Number}
  * @param {Number}
  * @param {Number}
  * @param {Number}
  */
  setCrop(x, y, width, height) {
    if (x==null || y==null || width==null || height==null) {
      this._crop = null
      return
    }
    this._crop = {x, y, width, height}
  }

  /**
  * Sets the flag whether the viewer should take over as full screen.
  * Full screen also disables sleep mode on devices.
  *
  * @param {Boolean}
  */
  setFullscreen(fullscreen) {
    this._fullscreenViewer = fullscreen
  }

  /**
  * Sets the flag whether a fully zoomed-out view should fill the screen.
  * If false, letterbox or side bars will be seen.
  *
  * @param {Boolean}
  */
  setFillViewer(fillViewer) {
    this._fillViewer = fillViewer
  }

  /**
  * Sets the pan speed factor in the config.
  *
  * Allows control of the pan speed by multiplying the specified factor
  * over the base speed (10 pixels per second).
  *
  * The speed is measured in 'webpage' pixels - so the speed appears
  * consistent no matter what resolution the source image is or at what
  * zoom level.
  *
  * Higher is faster, e.g.
  * - `1` = 10 px/sec
  * - `5` = 50 px/sec
  * - `20` = 200 px/sec
  *
  * @param {Number} - Pan speed multiplier
  */
  setSpeedFactor(speedFactor) {
    this._speedFactor = speedFactor
  }

  /**
  * Sets the spring stiffness in the underlying OpenSeadragon viewer.
  * Adjusts how 'springy' all subsequent pan movements appears.
  *
  * Higher is less linear, e.g.
  * - `1` = linear (pan exactly the same speed throughout the move)
  * - `2` = some spring (starts slow, speeds up, then slows as arrives)
  *
  * @param {Number} - Spring stiffness
  */
  applySpringStiffness(springStiffness) {
    // TODO this doesn't seem to adjust the stiffness properly
    this._viewer.viewport.centerSpringX.springStiffness = this._springStiffness
    this._viewer.viewport.centerSpringY.springStiffness = this._springStiffness
    this._viewer.viewport.zoomSpring.springStiffness = this._springStiffness
  }

  /**
  * Sets the specified animation duration (adjusted by the current speed factor)
  * in the underlying OpenSeadragon viewer.  Applies to all subsequent pan movements.
  * If current speed factor is less than zero, the duration is set at 0 seconds.
  *
  * @param {Number} - Pan duration in seconds
  */
  applyAnimationDuration(secs) {
    secs = secs / this._speedFactor
    if (this._speedFactor < 0) secs = 0
    this._viewer.viewport.centerSpringX.animationTime = secs
    this._viewer.viewport.centerSpringY.animationTime = secs
    this._viewer.viewport.zoomSpring.animationTime = secs
  }

  /**
  * Retrieves the viewport dimensions of the part of the image that is
  * currently visible in the webpage container. The pixels are coordinates
  * within the Viewport (OSD's special layer over the entire source image).
  * The returned rectangle is described as: x,y of the top left corner,
  * plus the rectangle's width and height.
  *
  * @return {OpenSeadragon.Rect} Viewport bounds currently visable in the webpage container
  */
  getCurrentViewportVisibleRegion() {
    return this._viewer.viewport.getBounds()
  }

  /**
  * Retrieves the image dimensions of the part of the image that is currently
  * visible in the webpage container. The pixels are coordinates within the
  * source image - as at it's original 100% resolution.
  * The returned rectangle is described as: x,y of the top left corner,
  * plus the rectangle's width and height.
  *
  * @return {OpenSeadragon.Rect} Image pixels currently visable in the webpage container
  */
  getCurrentImageVisibleRegion() {
    let img = this._viewer.viewport.viewportToImageRectangle(this._viewer.viewport.getBounds())
    // round calculations to whole numbers
    return new OpenSeadragon.Rect(Math.round(img.x), Math.round(img.y), Math.round(img.width), Math.round(img.height) )
  }

  /**
  * Starts automated play of the currently configured Pīwakawaka sequence.
  *
  * If full screen mode is enabled, switches the view and activates no-sleep.
  * NB: This step must be called as the result of a user action - Web browsers
  * do not allow scripts to switch to full screen by themselves.
  *
  * @param {Number} - Step in the sequence to play from, 0=step 1, empty=step 1, -1=resume
  */
  play(fromStepNo = 0) {
    console.log("PLAY sequence",this._sequence)
    this._viewerDiv.style.visibility = "visible"
    if (fromStepNo > -1) this._sequenceStep = fromStepNo
    this._paused = false
    if (this._fullscreenViewer) {
      this._viewer.setFullScreen(true)
      this._noSleep.enable()
    }
    if (fromStepNo < 0) {
      // temporarily mark as paused during delay (so no other steps jump the queue)
      //		this._paused = true
      // resuming, so zoom to where we last were
      //this._viewer.forceRedraw()
      console.log("resume location " + this._sequenceStep, this._imageVisibleRect)
      this.applyMoveTo(this._imageVisibleRect.x, this._imageVisibleRect.y, this._imageVisibleRect.width, this._imageVisibleRect.height, null, 0 )

      // TODO un pause

    } else {
      // reset (skip actions) conditions
      this.initConditions()
      // create OSD viewer if not already, or init with new image
      this.initViewer(this._imageLocation)
    }
    // start playing (once OSD has finished opening the image)
    this._viewer.addHandler('open', this.doNextSequenceAction.bind(this))
    if (this._playCallback) this._playCallback(this)
  }

  /**
  * Convenience method to play from the current step.
  */
  resume() {
    console.log("RESUMING")
    this.play(-1)
  }

  /**
  * Pauses automated sequence play. Can be resumed using `resume()`.
  *
  * Closes fullscreen display. Note that a long pan may continue running.
  */
  stop() {
    console.log("PAUSED")
    this._paused = true
    this._viewer.setFullScreen(false)
    this._noSleep.disable()
    this._viewerDiv.style.visibility = "hidden"
    if (this._stopCallback) this._stopCallback(this)
  }

  /**
  * Retrieves the next step in the sequence and performs its action.
  * If there are no more steps, it calls `stop()`.
  */
  doNextSequenceAction() {
    console.log("Do action, paused=" + this._paused)
    if (this._paused) return
    this._sequenceStep++
    console.log("Next action " + this._sequenceStep + " of " + this._sequence.length)
    if (this._sequence) {
      if (this._sequenceStep <= this._sequence.length) {
        let action = this._sequence[this._sequenceStep - 1]
        if (action.delay == null || action.delay < 0) action.delay = 0
        console.log("action (delay:"+action.delay+")",action)
        if (action.delay > 0) {
          // temporarily mark as paused during delay (so no other steps jump the queue)
          this._paused = true
          setTimeout(
            function(){
              this.applyMoveTo(action.x, action.y, action.width, action.height, action.label, action.duration, action.speed, action.spring, action.skip)
              // delay ended so un-pause
              this._paused = false
            }.bind(this),
            (action.delay * 1000)
          )
        } else {
          this.applyMoveTo(action.x, action.y, action.width, action.height, action.label, action.duration, action.speed, action.spring, action.skip)
        }
      } else {
        this.stop()
      }
    }
  }

  /**
  * Instructs the the underlying OpenSeadragon viewer to pan and zoom
  * so that it shows the specified part of the image
  * (specified using a rectangle's dimensions)
  * in the visible webpage container.
  *
  * If a speed factor is specified, it is used for _this_ move only
  * (afterwards the speed factor reverts to its previous value).
  *
  * @param {Number} - Target rectangle's left side in image pixels
  * @param {Number} - Target rectangle's top side in image pixels
  * @param {Number} - Target rectangle's width in image pixels
  * @param {Number} - Target rectangle's height in image pixels
  * @param {String} - Label to display in an overlay
  * @param {Number} - Duration in secs for this movement only (overrides speed factor)
  * @param {Number} - Speed factor to use for this movement only
  * @param {Number} - Spring stiffness to use for this movement only
  * @param {String} - Condition that (if true) will trigger that this action should be skipped
  */
  applyMoveTo(targetX, targetY, targetWidth, targetHeight, label, duration, speedFactor, springStiffness, skipCondition) {

    // remove any old label overlays
    this._viewer.removeOverlay(piwakawakaLabelOverlayId)

    // where are we currently?
    let viewportVisibleRect = this.getCurrentViewportVisibleRegion()
    this._imageVisibleRect = this.getCurrentImageVisibleRegion()
    let originalSpeedFactor = this._speedFactor
    let originalSpringStiffness = this._springStiffness
    console.log("imageSource: "+this._viewer.source.width+','+this._viewer.source.height)
    console.log("move from: " + this._imageVisibleRect.x + "," + this._imageVisibleRect.y + " (" + this._imageVisibleRect.width + "x" + this._imageVisibleRect.height + ")")

    // convert commands to actual image pixels
    let targetXfinal = this.commandToImagePixel(targetX)
    let targetYfinal = this.commandToImagePixel(targetY, 'y')
    targetWidth = this.commandToImagePixel(targetWidth)
    targetHeight = this.commandToImagePixel(targetHeight, 'y')

    // update any conditions triggered
    let overEdge = false
    //console.log("x " + targetX + "->" + targetXfinal + " curr:" + this._imageVisibleRect.x)
    //console.log("y " + targetY + "->" + targetYfinal + " curr:" + this._imageVisibleRect.y)
    // we are at edge if prev/next returns same as current
    if ((targetX == 'prev' || targetX == 'next') && targetXfinal == this._imageVisibleRect.x) overEdge = true
    if ((targetY == 'prev' || targetY == 'next') && targetYfinal == this._imageVisibleRect.y) overEdge = true
    // only update if not already triggered
    if (this._conditions.overEdge == false) this._conditions.overEdge = overEdge

    // trigger conditions
    // skip to next step if trying to do 'next' when already at edge
    if (this._conditions.overEdge && skipCondition == 'overEdge') {
      console.log("Skip step as over the edge")
      this.doNextSequenceAction()
      return
    }

    // copy current size if not specified
    if (targetWidth == null) targetWidth = this._imageVisibleRect.width
    if (targetHeight == null) targetHeight = this._imageVisibleRect.height

    // we want a consistant pan speed (as the user experiences it)
    // so we use web pixel distance rather than source image pixel distance
    // (as more/less image pixels may pass depending on the current zoom level)
    let webDistanceDirect = Piwakawaka.calcDirectDistance(this._imageVisibleRect.x, this._imageVisibleRect.y, targetXfinal, targetYfinal)
    // want to travel over X web pixels/sec, so divide pixels by base speed
    let animationTimeSecs = webDistanceDirect / Piwakawaka.defaultSpeedWebPixelsPerSecond
    // update speed and spring if supplied
    if (speedFactor != null) this.setSpeedFactor(speedFactor)
    if (springStiffness != null) this.applySpringStiffness(springStiffness)
    // override speed with duration if provded
    if (duration) {
      animationTimeSecs = duration
      // we don't want our time adjusted by the speed factor
      this.setSpeedFactor(1)
    }
    // configure the duration for this upcoming movement
    this.applyAnimationDuration(animationTimeSecs)

    // convert target image pixels to viewport coordinates
    let targetViewportCoordinates = this._viewer.viewport.imageToViewportCoordinates(targetXfinal, targetYfinal)
    let targetViewportSize = this._viewer.viewport.imageToViewportCoordinates(targetWidth, targetHeight)

    // update viewport bounds with our new target location
    viewportVisibleRect.x = targetViewportCoordinates.x
    viewportVisibleRect.y = targetViewportCoordinates.y
    viewportVisibleRect.width = targetViewportSize.x
    viewportVisibleRect.height = targetViewportSize.y

    // move there
    console.log("move to  : " + targetXfinal + "," + targetYfinal + " (" + targetWidth + "x" + targetHeight + ")" + " web-dist:"+webDistanceDirect+" secs:"+animationTimeSecs)
    console.log("viewportVisibleRect", viewportVisibleRect)
    let immediately = false
    if (speedFactor == -1) immediately = true
    this._viewer.viewport.fitBoundsWithConstraints(viewportVisibleRect, immediately)

    // TODO the label should be added after the movement has finished (better control of placement)
    // TODO add a way to continue after labelled hotspot, e.g. key press, timed delay

    // add the label as an overlay
    if (label != null) {
      this._overlayElement.innerHTML = label
      this._viewer.addOverlay({
        element: this._overlayElement,
        location: new OpenSeadragon.Point(0.21,0.2),
        placement: OpenSeadragon.Placement.CENTER
      });
    }

    // revert to the previous speed/spring
    this.setSpeedFactor(originalSpeedFactor)
    this.applySpringStiffness(originalSpringStiffness)
  }

  /**
  * Converts movement commands to actual target image pixels.
  * Known commands are converted to numbers, anything else
  * (numbers or unknown commands) are returned unchanged.
  * Calculates locations along the single dimension specified.
  *
  * Commands:
  * - `min` = minimum image pixel on this dimension (0 or cropped min)
  * - `max` = maximum image pixel on this dimension (image or cropped max)
  * - `same` = current image pixel location (i.e. no change)
  * - `next` = location that scrolls forward by the size that is currently visible
  * - `prev` = location that scrolls backwards by the size that is currently visible
  *
  * @param {String} - Pixel {Number}, or command {String} to convert to pixel
  * @param {String} - Dimension: `x` (horizontal, default), `y` (vertical)
  * @return {Number} Calculated target pixel value
  */
  commandToImagePixel(value, dimension) {
    let imageSource = this._viewer.source
    this._imageVisibleRect = this.getCurrentImageVisibleRegion()

    // set up measurements for x dimension
    let imageMin = 0
    let imageMax = imageSource.width
    let imageCurrent = this._imageVisibleRect.x
    let imageVisibleSize = this._imageVisibleRect.width
    // reduce measurements if cropping (setCrop() ensures non-null values)
    if (this._crop != null) {
      imageMin = this._crop.x
      imageMax = this._crop.x + this._crop.width
    }

    // switch to y dimension if specified
    if (dimension === 'y') {
      imageMax = imageSource.height;
      imageCurrent = this._imageVisibleRect.y
      imageVisibleSize = this._imageVisibleRect.height
      // reduce measurements if cropping (setCrop() ensures non-null values)
      if (this._crop != null) {
        imageMin = this._crop.y
        imageMax = this._crop.y + this._crop.height
      }
    }

    switch (value) {
      case 'min':
      case 'minimum':
        value = imageMin;
        break
      case 'max':
      case 'maximum':
        value = imageMax
        break
      case 'same':
        value = imageCurrent
        break
      case 'next':
        // add on the size that is visible so we move just beyond it
        value = imageCurrent + imageVisibleSize
        // but don't go over the edge
        let edge = imageMax - imageVisibleSize
        if (value > edge) value = edge
        break
      case 'prev':
      case 'previous':
        value = imageCurrent - imageVisibleSize
        // don't go over the edge
        if (value < 0) value = 0
        break
    }
    return value
  }

  /**
  * Creates an OpenSeadragon (OSD) viewer for the specified image.
  * The viewer is locked down so the user can't do much - this
  * navigator will control it.
  *
  * @param {String} - IIIF Image URL (info.json)
  */
  initViewer(imageUrl) {
    console.log('init viewer: '+imageUrl)

    if (this._viewer) {
      console.log("updating OSD viewer to new image")
      // TODO these levels don't seem to have an effect
      let minZoomLevel = (this._fillViewer ? 1 : 0)
      let defaultZoomLevel = (this._fillViewer ? 1 : 0)
      this._viewer.defaultZoomLevel = 0
      this._viewer.minZoomLevel = 0
      this._viewer.open(imageUrl)
      return
    }

    console.log("creating OSD viewer")
    let gestureSettings = {
      scrollToZoom: false,
      clickToZoom: false,
      dblClickToZoom: false,
      pinchToZoom: false,
      pinchRotate: false,
      flickEnabled: false
    }
    let minZoomLevel = (this._fillViewer ? 1 : 0)
    let defaultZoomLevel = (this._fillViewer ? 1 : 0)
    this._viewer = OpenSeadragon({
      id: piwakawakaViewerId,
      prefixUrl: "https://cdnjs.cloudflare.com/ajax/libs/openseadragon/2.3.1/images/",
      tileSources: imageUrl,
      crossOriginPolicy: "Anonymous",
      minZoomLevel: minZoomLevel,
      defaultZoomLevel: defaultZoomLevel,
      sequenceMode: true,
      visibilityRatio: 1,
      constrainDuringPan: true,
      gestureSettingsMouse: gestureSettings,
      gestureSettingsTouch: gestureSettings,
      gestureSettingsPen: gestureSettings,
      gestureSettingsUnknown: gestureSettings,
      panHorizontal: false,
      panVertical: false,
      showNavigationControl: true,
      autoHideControls: true,
      showZoomControl: false,
      showHomeControl: false,
      showSequenceControl: false,
      showRotationControl: false,
      showFullPageControl: false
    })

    // add exit button, if not there already
    if (this._stopButton == null) {
      this._stopButton = new OpenSeadragon.Button({
        tooltip: 'Close',
        srcRest: 'https://cdnjs.cloudflare.com/ajax/libs/openseadragon/2.3.1/images/previous_rest.png',
        srcGroup: 'https://cdnjs.cloudflare.com/ajax/libs/openseadragon/2.3.1/images/previous_grouphover.png',
        srcHover: 'https://cdnjs.cloudflare.com/ajax/libs/openseadragon/2.3.1/images/previous_hover.png',
        srcDown: 'https://cdnjs.cloudflare.com/ajax/libs/openseadragon/2.3.1/images/previous_pressed.png',
        onClick: this.stop.bind(this),
        fadeDelay: 5000,
        fadeLength: 2000
      });
      this._viewer.addControl(this._stopButton.element, {
        anchor: OpenSeadragon.ControlAnchor.BOTTOM_LEFT
      });
    }

    // set up an ongoing trigger in the OSD viewer,
    // to do the next action after each animation finishes
    this._viewer.addHandler('animation-finish', this.doNextSequenceAction.bind(this))

    // catch exiting the viewer by ESCaping out of full-screen
    this._viewer.addHandler('full-screen', this.fullscreenEventHandler.bind(this))
  }

  fullscreenEventHandler(event) {
    if (event.fullScreen == false) this.stop()
  }

  getViewer() {
    return this._viewer
  }

  /**
  * Calculates the distance in pixels of a direct/diagonal line
  * from the specified start point (x,y) to the specified end point (x,y).
  * @param {Number}
  * @param {Number}
  * @param {Number}
  * @param {Number}
  * @return {Number} Approximate distance in pixels
  */
  static calcDirectDistance(startX, startY, endX, endY) {
    // using Pythagorean theorem for diagonal distance: sqrt( change(x)*2 + change(y)*2 )
    let distanceX = Math.abs(endX - startX)
    let distanceY = Math.abs(endY - startY)
    let distanceDirect = Math.round( Math.sqrt( Math.pow(distanceX,2) + Math.pow(distanceY,2) ) )
    return distanceDirect
  }

  /**
  * Retrieves the value of the specified parameter name from the querystring in the supplied URL.
  *
  * @param {String} - Querystring parameter name
  * @param {String} - URL (use `null` for the current webpage URL)
  * @return {Number} Parameter's value
  */
  static getParameterByName(parameterName, url) {
    if (url === undefined) url = window.location.href;
    parameterName = parameterName.replace(/[\[\]]/g, "\\$&");
    let regex = new RegExp("[?&]" + parameterName + "(=([^&#]*)|&|#|$)"),
    results = regex.exec(url);
    if (!results) return null;
    if (!results[2]) return '';
    return decodeURIComponent(results[2].replace(/\+/g, " "));
  }
}

/**
* Default base speed for panning in webpage pixels per seconds.
* @constant
* @type {Number}
* @default
*/
Piwakawaka.defaultSpeedWebPixelsPerSecond = 10.0

/**
* Default whether full view fills screen (crops), or letterboxed.
* @constant
* @type {Boolean}
* @default
*/
Piwakawaka.defaultFillViewer = true

/**
* Default pan speed adjustment factor (over the base speed).
* @constant
* @type {Number}
* @default
*/
Piwakawaka.defaultSpeedFactor = 10.0

/**
* Default pan movement stiffness (1 = linear).
* @constant
* @type {Number}
* @default
*/
Piwakawaka.defaultSpringStiffness = 1.0