Source: src/svgCoords.js

/** @module svgCoords */

import * as d3 from 'd3'

/**
 * @typedef {Object} transOptsSel
 * @property {string} key - there must be at least one, but potentially more, properties
 * on this object, each describing a map 'transformation'.
 */

/**
 * @typedef {Object} transOpts - A 'transformation' options object simply defines the extents of the
 * map, potentially with insets too.
 * @property {string} id - this must match the key by which the object is accessed through
 * the parent object.
 * @property {string} caption - a human readable name for this transformation options object.
 * @property {module:svgCoords~transOptsBounds} bounds - an object defining the extents of the map.
 * @property {Array.<module:svgCoords~transOptsInset>} insets - an array of objects defining the inset portions of the map. 
 */

/**
 * @typedef {Object} transOptsInset - an object defining an inset for a map, i.e. part of a map
 * which will be displayed in a different location to that in which it is actually found 
 * @property {module:svgCoords~transOptsBounds} bounds - an object defining the extents of the inset.
 * @property {number} imageX - a value defining where the inset will be displayed
 * (displaced) on the SVG. If the number is positive it represents the number of 
 * pixels the left boundary of the inset will be positioned from the left margin of
 * the SVG. If it is negative, it represents the number of pixels the right boundary
 * of the inset will be positioned from the right boundary of the SVG.
 * @property {number} imageY - a value defining where the inset will be displayed
 * (displaced) on the SVG. If the number is positive it represents the number of 
 * pixels the botton boundary of the inset will be positioned from the bottom margin of
 * the SVG. If it is negative, it represents the number of pixels the top boundary
 * of the inset will be positioned from the top boundary of the SVG.
 */

 /**
 * @typedef {Object} transOptsBounds - an object defining the extents of the map, 
 * or portion of a mpa, in the projection system
 * you want to use (either British Nation Gid, Irish National Grid or UTM 30 N for Channel Islands).
 * properties on this element are xmin, ymin, xmax and ymax.
 * @property {number} xmin - the x value for the lower left corner.
 * @property {number} ymin - the y value for the lower left corner.
 * @property {number} xmax - the x value for the top right corner.
 * @property {number} ymax - the y value for the top right corner.
 */

/**
 * Given a transform options object, describing a bounding rectangle in world coordinates,
 * and a height dimension, this function returns an array of objects - one
 * for each inset described in the transform object - that describe a set of
 * rectangles corresponding to each of the insets. Each object has an origin
 * corresponding to the top-left of the rectangle, a width and a height dimension.
 * The dimensions and coordiates are relative to the height argument. A typical
 * use of these metrics would be to draw an SVG rectagle around an inset.
 * @param {module:svgCoords~transOpts} transOpts - The transformation options object.
 * @param {number} outputHeight - The height, e.g. height in pixels, of an SVG element.
 * @returns {Array<Object>}
 */
function getInsetDims(transOpts, outputHeight) {

  const outputWidth = widthFromHeight(transOpts, outputHeight)
  const transform = transformFunction(transOpts, outputHeight)
  const insetDims = []
  
  if (transOpts.insets) {
    transOpts.insets.forEach(function(inset) {
      const ll = transform([inset.bounds.xmin, inset.bounds.ymin])
      const ur = transform([inset.bounds.xmax, inset.bounds.ymax])
      const iWidth = ur[0]-ll[0]
      const iHeight = ll[1]-ur[1]

      insetDims.push ({
        x: inset.imageX < 0 ? outputWidth - iWidth + inset.imageX : inset.imageX,
        y: inset.imageY < 0 ? - inset.imageY : outputHeight - inset.imageY - iHeight,
        width: iWidth,
        height: iHeight
      })
    })
  }
  return insetDims
}

/**
 * Given a transform options object, describing a bounding rectangle in world coordinates,
 * and a height dimension, this function returns a width dimension
 * that respects the aspect ratio described by the bounding rectangle.
 * @param {module:svgCoords~transOpts} transOpts - The transformation options object.
 * @param {number} outputHeight - The height, e.g. height in pixels, of an SVG element.
 * @returns {number}
 */
function widthFromHeight(transOpts, outputHeight) {
  const realWidth = transOpts.bounds.xmax - transOpts.bounds.xmin
  const realHeight = transOpts.bounds.ymax - transOpts.bounds.ymin
  return outputHeight * realWidth/realHeight
}

/**
 * Given a transform options object, describing a bounding rectangle in world coordinates,
 * and a height dimension, this function returns a new function that will accept a
 * point argument - normally describing real world coordinates - and returns a 
 * point that is transformed to be within the range 0 - outputHeight (for y)
 * and 0 - outputWidth (for x). This function can be used as input to a d3.geoTransform
 * to provide a transformation to d3.geoPath to draw an SVG path from a geojson file.
 * The transOpts argument is an object which can also describe areas which should
 * displaced in the output. This can be used for displaying islands in an
 * inset, e.g. the Channel Islands.
 * @param {module:svgCoords~transOpts} transOpts - The transformation options object.
 * @param {number} outputHeight - The height, e.g. height in pixels, of an SVG element.
 * @returns {function}
 */
function transformFunction(transOpts, outputHeight) {

  const realWidth = transOpts.bounds.xmax - transOpts.bounds.xmin
  const realHeight = transOpts.bounds.ymax - transOpts.bounds.ymin
  const outputWidth = widthFromHeight(transOpts, outputHeight)

  return function(p, ignoreInset) {
    const x = p[0]
    const y = p[1]
    let tX, tY

    tX = outputWidth * (x-transOpts.bounds.xmin)/realWidth
    tY = outputHeight - outputHeight * (y-transOpts.bounds.ymin)/realHeight

    if (!ignoreInset && transOpts.insets && transOpts.insets.length > 0) {
      transOpts.insets.forEach(function(inset) {
      
        if (x >= inset.bounds.xmin &&  x <= inset.bounds.xmax && y >= inset.bounds.ymin &&  y <= inset.bounds.ymax) {

          const insetX = outputWidth * (inset.bounds.xmin-transOpts.bounds.xmin)/realWidth
          const insetY = outputHeight - outputHeight * (inset.bounds.ymin-transOpts.bounds.ymin)/realHeight

          // Coordinates are within bounds on an inset
          // Adjust inset origns - negative are offsets of max inset from max output

          let imageX, imageY
          if (inset.imageX < 0 && !transOpts.forTween) {
            imageX = outputWidth + inset.imageX - ((inset.bounds.xmax - inset.bounds.xmin) / realWidth * outputWidth)
          } else {
            imageX=inset.imageX
          }
          if (inset.imageY < 0 && !transOpts.forTween) {
            imageY = outputHeight + inset.imageY - ((inset.bounds.ymax - inset.bounds.ymin) / realHeight * outputHeight)
          } else {
            imageY=inset.imageY
          }

          tX = tX - insetX + imageX
          tY = outputHeight - imageY - (insetY - tY) 
        }
      })
    }
    return [tX, tY]
  }
}

/**
 * Given a transform options object, describing a bounding rectangle in world coordinates,
 * and a height dimension, this function returns an object that encapsulates the transformation
 * options and provides additional transformation functionality and other information.
 * @param {module:svgCoords~transOpts} transOpts - The transformation options object.
 * @param {number} outputHeight - The height, e.g. height in pixels, of an SVG element.
 * @returns {Object} transopts - The transformation object.
 * @returns {Object} transopts.params - The transformation options object.
 * @returns {Array<Object>} transopts.insetDims - An array of objects defining the position and size of insets.
 * @returns {number} transopts.height - The height, e.g. height in pixels, of the SVG element.
 * @returns {number} transopts.width - The width, e.g. width in pixels, of the SVG element.
 * @returns {function} transopts.point - A function that will take a point object describing real world coordinates and return the SVG coordinates.
 * @returns {function} transopts.d3Path - A function that will take a geoJson path in real world coordinates and return the SVG path.
 */
export function createTrans (transOpts, outputHeight) {
  const transform = transformFunction(transOpts, outputHeight)
  return {
    params: transOpts,
    insetDims: getInsetDims(transOpts, outputHeight),
    height: outputHeight,
    width: widthFromHeight(transOpts, outputHeight),
    point: transform,
    d3Path: d3.geoPath()
      .projection(
        d3.geoTransform({
          point: function(x, y) {
            const tP = transform([x,y])
            const tX = tP[0]
            const tY = tP[1]
            this.stream.point(tX, tY)
          }
        })
      )
  }
}

// Defined insets required for namedTransOpts
const boundsChannelIslands_gb = {
  xmin: 337373,
  ymin: -92599,
  xmax: 427671,
  ymax: -6678
}
const boundsNorthernIsles_gb = {
  xmin: 312667,
  ymin: 980030,
  xmax: 475291,
  ymax: 1225003
}

/** @constant
  *  @description This object contains some named objects that are in the correct 
  * format to be used as transOpts arguments to some of the functions in this module.
  * Using one of these may save you generating one of your own. The main bounds element
  * indicates the extent of the main map (in real world coordinates). The bounds of
  * inset objects indicate the extent that is to be offset within the map image.
  * The imageX and imageY values of an inset object indicates the position of the offset
  * portion within the map in pixels. Positve x and y values offset the inset from the
  * left and bottom of the image respecitvely. Negative x and y values offset the inset
  * from the right and top of the image respectively.
  * Each transOpts object also has a property called 'id' which must be set to the
  * value used as a key to the object in the parent object. Each also has a
  * property called 'caption' which has a name which can be displayed if offering
  * users a choice between transformation objects.
  * <ul>
  * <li> <b>namedTransOpts.BI1</b> is a bounding box, in EPSG:27700, for the 
  * British Ilses that includes the Channel Islands in their natural position.
  * <li> <b>namedTransOpts.BI2</b> is a bounding box, in EPSG:27700, for the 
  * British Isles, that doesn't extend as far south as the 
  * Channel Islands, but with an inset covering the Channel Isles, 
  * offset 25 pixels from the bottom left corner of the output.
  * <li> <b>namedTransOpts.BI3</b> is a bounding box, in EPSG:27700, for 
  * the British Isles, that doesn't extend as far north as the Northern Isles.
  * An inset covering the Northern Isles, is offset 25 pixels from the 
  * top right corner of the output.
  * <li> <b>namedTransOpts.BI4</b> is a bounding box, in EPSG:27700, for 
  * the British Isles, that doesn't extend as far south as the 
  * Channel Islands or as far north as the Northern Isles. An inset covering 
  * the Channel Isles, is offset 25 pixels from the bottom left corner of the output.
  * An inset covering the Northern Isles, is offset 25 pixels from the 
  * top right corner of the output.
  * </ul>
  *  @type {object}
*/
export const namedTransOpts = {
  BI1: {
    id: 'BI1',
    caption: 'No insets',
    bounds: {
      xmin: -213389,
      ymin: -113239,
      xmax: 702813,
      ymax: 1237242
    },
  },
  BI2: {
    id: 'BI2',
    caption: 'Inset Channel Islands (CI)',
    bounds: {
      xmin: -213389,
      ymin: -9939,
      xmax: 702813,
      ymax: 1237242
    },
    insets: [{
      bounds: boundsChannelIslands_gb,
      imageX: 25,
      imageY: 25
    }]
  },
  BI3: {
    id: 'BI3',
    caption: 'Inset Northern Isles',
    bounds: {
      xmin: -213389,
      ymin: -9939,
      xmax: 702813,
      ymax: 1050000
    },
    insets: [{
      bounds: boundsNorthernIsles_gb,
      imageX: -25,
      imageY: -25
    }]
  },
  BI4: {
    id: 'BI4',
    caption: 'Inset CI & Northern Isles',
    bounds: {
      xmin: -213389,
      ymin: -9939,
      xmax: 702813,
      ymax: 1050000
    },
    insets: [{
      bounds: boundsChannelIslands_gb,
      imageX: 25,
      imageY: 25
    },
    {
      bounds: boundsNorthernIsles_gb,
      imageX: -25,
      imageY: -25
    }]
  }
}

/**
 * Given both 'from' and 'to' transform options objects, an output height and a
 * 'tween' value between 0 and 1, this function returns a transform option object
 * for which the map bounds, the inset bounds and the inset image position
 * are all interpolated between the 'from' and 'to' objects at a position
 * depending on the value of the tween value. Typically this would then be used
 * to help generate a path transformation to use with D3 to animate transitions
 * between different map transformations. Note that this only works with
 * named transformation objects defined in this library.
 * @param {module:svgCoords~transOpts} from - The 'from' transformation options object.
 * @param {module:svgCoords~transOpts} to - The 'to' transformation options object.
 * @param {number} outputHeight - The height, e.g. height in pixels, of an SVG element.
 * @param {number} tween - Between 0 and 1 indicating the interpolation position.
 * @returns {module:svgCoords~transOpts} - Intermediate transformation options object.
 */
export function getTweenTransOpts(from, to, outputHeight, tween){
  
  const fto = copyTransOptsForTween(namedTransOpts[from], outputHeight)
  const tto = copyTransOptsForTween(namedTransOpts[to], outputHeight)

  let rto = {
    bounds: {
      xmin: fto.bounds.xmin + (tto.bounds.xmin - fto.bounds.xmin) * tween,
      xmax: fto.bounds.xmax + (tto.bounds.xmax - fto.bounds.xmax) * tween,
      ymin: fto.bounds.ymin + (tto.bounds.ymin - fto.bounds.ymin) * tween,
      ymax: fto.bounds.ymax + (tto.bounds.ymax - fto.bounds.ymax) * tween
    },
    insets: [],
    forTween: true // Means that negative image positions won't be translated by transformFunction
  }
  fto.insets.forEach(function(i,idx){
    rto.insets.push({
      bounds: {
        xmin: i.bounds.xmin + (tto.insets[idx].bounds.xmin - i.bounds.xmin) * tween,
        xmax: i.bounds.xmax + (tto.insets[idx].bounds.xmax - i.bounds.xmax) * tween,
        ymin: i.bounds.ymin + (tto.insets[idx].bounds.ymin - i.bounds.ymin) * tween,
        ymax: i.bounds.ymax + (tto.insets[idx].bounds.ymax - i.bounds.ymax) * tween
      },
      imageX: i.imageX + (tto.insets[idx].imageX - i.imageX) * tween,
      imageY: i.imageY + (tto.insets[idx].imageY - i.imageY) * tween
    })
  })
  return rto
}

function copyTransOptsForTween(transOpts, outputHeight) {

  // This function makes a copy of a transformation object. The copy is different
  // from the original in two respects. Firstly the image positions of the insets
  // are expressed as positive numbers (from bottom or left of image)
  // even when expressed as negative offsets (from top or right of image) in the
  // original. Secondly all named insets used in this library are represented in
  // the returned object even if not present in the original. Such insets are
  // given image positions that reflect their real world positions.

  const insetDims = getInsetDims(transOpts, outputHeight)

  let tto = {
    bounds: {
      xmin: transOpts.bounds.xmin,
      xmax: transOpts.bounds.xmax,
      ymin: transOpts.bounds.ymin,
      ymax: transOpts.bounds.ymax
    },
    insets: []
  }
  if (transOpts.insets){
    transOpts.insets.forEach(function(i, idx){
      const iNew = {
        bounds: {
          xmin: i.bounds.xmin,
          xmax: i.bounds.xmax,
          ymin: i.bounds.ymin,
          ymax: i.bounds.ymax
        },
      }
      // Usng the calculated insetDims translates any negative numbers - used
      // as shorthand for defining position offsets from top or right margin - to 
      // positive values from bottom and left.
      iNew.imageX = insetDims[idx].x,
      iNew.imageY = outputHeight - insetDims[idx].y - insetDims[idx].height
      tto.insets.push(iNew)
    })
  }

  let insetCi, insetNi
  tto.insets.forEach(function(i){
    if (i.bounds.xmin === boundsChannelIslands_gb.xmin) {
      insetCi = true
    }
    if (i.bounds.xmin === boundsNorthernIsles_gb.xmin) {
      insetNi = true
    }
  })

  if (!insetCi) {
    tto.insets.unshift({
      bounds: boundsChannelIslands_gb,
      imageX: (boundsChannelIslands_gb.xmin - tto.bounds.xmin) / (tto.bounds.xmax - tto.bounds.xmin) * widthFromHeight(tto, outputHeight),
      imageY: (boundsChannelIslands_gb.ymin - tto.bounds.ymin) / (tto.bounds.ymax - tto.bounds.ymin) * outputHeight
    })
  }

  if (!insetNi) {
    tto.insets.push({
      bounds: boundsNorthernIsles_gb,
      imageX: (boundsNorthernIsles_gb.xmin - tto.bounds.xmin) / (tto.bounds.xmax - tto.bounds.xmin) * widthFromHeight(tto, outputHeight),
      imageY: (boundsNorthernIsles_gb.ymin - tto.bounds.ymin ) / (tto.bounds.ymax - tto.bounds.ymin) * outputHeight
    })
  }
  return tto
}

export function getRadiusPixels(transform, precision){
  return Math.abs(transform([300000,300000])[0]-transform([300000+precision/2,300000])[0])
}