langateam/trailpack-mapnik

View on GitHub
api/services/MapService.js

Summary

Maintainability
A
3 hrs
Test Coverage
'use strict'

const mapnik = require('@langa/mapnik')

mapnik.Logger.setSeverity(mapnik.Logger.DEBUG)

/**
 * @module MapService
 * @description TODO document Service
 */
module.exports = class MapService extends Service {

  /**
   * @param source String mapnik source
   * @param map.bbox Array The bounding box of the map (w, s, e, n)
   * @param map.width Number The width of the output image in pixels
   * @param map.height Number The height of the output image in pixels
   */
  getMap(source, { bbox: [ w, s, e, n], width, height }) {
    const configFile = this.app.config.mapnik.maps[source].pathname
    const map = new mapnik.Map(parseInt(width), parseInt(height))

    return new Promise((resolve, reject) => {
      map.load(configFile, (err, map) => {
        if (err) {
          this.log.warn(err)
          return reject(err)
        }

        map.extent = [ w, s, e, n ]

        const image = new mapnik.Image(parseInt(width), parseInt(height))

        this.log.debug(`MapService.getMap RENDERING src=${source} w=${width} h=${height}`)
        map.render(image, { scale: 1, buffer_size: 1 }, (err, image) => {
          if (err) return reject(err)

          this.log.debug(`MapService.getMap ENCODING IMAGE src=${source} w=${width} h=${height}`)
          image.encode('png', (err, buffer) => {
            if (err) return reject(err)

            resolve({ buffer, headers: { 'Content-Type': 'image/png' } })
          })
        })
      })
    })
  }

  getTile (source, { x, y, z }) {
    return this.validateTileParameters(source, {x, y, z})
      .then(({x, y, z}) => {
        return this.downloadTile(source, {x, y, z})
      })
      .then(tile => {
        if (tile) return tile

        return this.renderTile(source, {x, y, z})
          .then(tile => {
            // async. do not wait for tile upload. return immediately
            process.nextTick(() => this.uploadTile(source, {x, y, z}, tile))

            return tile
          })
      })
  }

  downloadTile (source, {x, y, z}) {
    const s3cache = this.app.config.mapnik.s3cache[source]
    const AWSService = this.app.services.AWSService
    const t0 = Date.now()

    return new Promise((resolve, reject) => {
      AWSService.S3.getObject({
        Bucket: s3cache.Bucket,
        Key: s3cache.getKey({ x, y, z })
      }, (err, data) => {
        if (err && err.name === 'NoSuchKey') {
          this.log.info(`Tile cache miss: ${source}/${z}/${x}/${y}`)
          resolve()
        }
        else if (err) {
          this.log.warn('S3 Error:', err)
          resolve()
        }
        else if (Buffer.isBuffer(data.Body)) {
          this.log.info(`Retrieved tile ${source}/${z}/${x}/${y} from s3 in ${(Date.now() - t0)}ms`)
          resolve({
            tile: data.Body,
            headers: {
              'Content-Type': data.ContentType
            }
          })
        }
        else {
          this.log.warn(`Tile from S3 ${source}/${z}/${x}/${y} is not a buffer.`)
          resolve()
        }
      })
    })
  }

  uploadTile (source, {x, y, z}, { tile, headers }) {
    const AWSService = this.app.services.AWSService
    const s3cache = this.app.config.mapnik.s3cache[source]

    this.log.debug(`Caching tile ${source}/${z}/${x}/${y} in bucket ${s3cache.Bucket}...`)

    const t1 = Date.now()
    AWSService.S3.putObject({
      Bucket: s3cache.Bucket,
      ContentType: headers['Content-Type'],
      Key: s3cache.getKey({ x, y, z }),
      Body: tile
    }, (err, data) => {
      if (err) {
        this.log.warn('S3 Cache failed on tile [', z, x, y, ']:', err)
      }
      else {
        this.log.info(`Cached tile ${source}/${z}/${x}/${y} in ${(Date.now() - t1)}ms`)
      }
    })
  }

  renderTile (source, {x, y, z}) {
    const t0 = Date.now()

    return new Promise((resolve, reject) => {
      this.app.packs.mapnik.sources[source].getTile(z, x, y, (err, tile, headers) => {
        if (err) return reject(err)

        this.log.debug(`Tile rendered: ${source}/${z}/${x}/${y},`,
          `size=${Math.round(tile.length / 1024)}kb`,
          `elapsed=${(Date.now() - t0)}ms`)

        resolve({ tile, headers })
      })
    })
  }

  validateTileParameters (source, {x, y, z}) {
    const Source = this.app.packs.mapnik.sources[source]
    x = parseInt(x)
    y = parseInt(y)
    z = parseInt(z)

    if (!Source) {
      return Promise.reject(new Error(`Tile source "${source}" is not available.`))
    }

    if (isNaN(x) || isNaN(y) || isNaN(z)) {
      return Promise.reject(new Error(`Tile coordinates [z=${z}, x=${x}, y=${y}] invalid.`))
    }

    return Promise.resolve({x, y, z})
  }
}