leaflet-simple-map-screenshoter
Version:
Leaflet plugin plugin which take screenshot of map
496 lines (462 loc) • 18.9 kB
JavaScript
/* global L */
import domtoimage from 'dom-to-image-more'
import fileSaver from 'file-saver'
export const STATUS_READY = 1
export const STATUS_PENDING = 2
export const ICON_SVG_BASE64 = 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iaXNvLTg4NTktMSI/PjxzdmcgdmVyc2lvbj0iMS4xIiBpZD0iQ2FwYV8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4PSIwcHgiIHk9IjBweCIgdmlld0JveD0iMCAwIDMyIDMyIiBzdHlsZT0iZW5hYmxlLWJhY2tncm91bmQ6bmV3IDAgMCAzMiAzMjsiIHhtbDpzcGFjZT0icHJlc2VydmUiPjxnPjxnIGlkPSJjYW1lcmEiPjxwYXRoIHN0eWxlPSJmaWxsOiMwMTAwMDI7IiBkPSJNMTYsOS41MDFjLTQuNDE5LDAtOCwzLjU4MS04LDhjMCw0LjQxOCwzLjU4MSw4LDgsOGM0LjQxOCwwLDgtMy41ODIsOC04UzIwLjQxOCw5LjUwMSwxNiw5LjUwMXogTTIwLjU1NSwyMS40MDZjLTIuMTU2LDIuNTE2LTUuOTQzLDIuODA3LTguNDU5LDAuNjVjLTIuNTE3LTIuMTU2LTIuODA3LTUuOTQ0LTAuNjUtOC40NTljMi4xNTUtMi41MTcsNS45NDMtMi44MDcsOC40NTktMC42NUMyMi40MiwxNS4xMDIsMjIuNzExLDE4Ljg5MSwyMC41NTUsMjEuNDA2eiIvPjxwYXRoIHN0eWxlPSJmaWxsOiMwMTAwMDI7IiBkPSJNMTYsMTMuNTAxYy0yLjIwOSwwLTMuOTk5LDEuNzkxLTQsMy45OTl2MC4wMDJjMCwwLjI3NSwwLjIyNCwwLjUsMC41LDAuNXMwLjUtMC4yMjUsMC41LTAuNVYxNy41YzAuMDAxLTEuNjU2LDEuMzQzLTIuOTk5LDMtMi45OTljMC4yNzYsMCwwLjUtMC4yMjQsMC41LTAuNVMxNi4yNzYsMTMuNTAxLDE2LDEzLjUwMXoiLz48cGF0aCBzdHlsZT0iZmlsbDojMDEwMDAyOyIgZD0iTTI5LjQ5Miw4LjU0MmwtNC4zMzQtMC43MjNsLTEuMzczLTMuNDM0QzIzLjMyNiwzLjI0LDIyLjIzMiwyLjUsMjEsMi41SDExYy0xLjIzMiwwLTIuMzI2LDAuNzQtMi43ODYsMS44ODZMNi44NDIsNy44MTlMMi41MDksOC41NDJDMS4wNTUsOC43ODMsMCwxMC4wMjcsMCwxMS41djE1YzAsMS42NTQsMS4zNDYsMywzLDNoMjZjMS42NTQsMCwzLTEuMzQ2LDMtM3YtMTVDMzIsMTAuMDI3LDMwLjk0NSw4Ljc4MywyOS40OTIsOC41NDJ6IE0zMCwyNi41YzAsMC41NTMtMC40NDcsMS0xLDFIM2MtMC41NTMsMC0xLTAuNDQ3LTEtMXYtMTVjMC0wLjQ4OSwwLjM1NC0wLjkwNiwwLjgzNi0wLjk4Nkw4LjI4LDkuNjA3bDEuNzkxLTQuNDc4QzEwLjIyNCw0Ljc1LDEwLjU5MSw0LjUsMTEsNC41aDEwYzAuNDA4LDAsMC43NzUsMC4yNDksMC45MjgsMC42MjlsMS43OTEsNC40NzhsNS40NDUsMC45MDdDMjkuNjQ2LDEwLjU5NCwzMCwxMS4wMTEsMzAsMTEuNVYyNi41eiIvPjwvZz48L2c+PGc+PC9nPjxnPjwvZz48Zz48L2c+PGc+PC9nPjxnPjwvZz48Zz48L2c+PGc+PC9nPjxnPjwvZz48Zz48L2c+PGc+PC9nPjxnPjwvZz48Zz48L2c+PGc+PC9nPjxnPjwvZz48Zz48L2c+PC9zdmc+'
export const SimpleMapScreenshoter = L.Control.extend({
options: {
cropImageByInnerWH: true,
hidden: false,
domtoimageOptions: {},
position: 'topleft',
screenName: 'screen',
iconUrl: ICON_SVG_BASE64,
hideElementsWithSelectors: [
'.leaflet-control-container'
],
onCropBorderSize: 2,
caption: null,
captionFontSize: 15,
captionFont: 'Arial',
captionColor: 'black',
captionBgColor: 'white',
captionOffset: 5,
mimeType: 'image/png',
debugMode: false,
preventDownload: false,
onPixelDataFail: function ({ node, error }) {
console.warn(`Map node is very big ${node.scrollWidth}x${node.scrollHeight}`)
console.warn(`Add function: SimpleMapScreenshoter({
onPixelDataFail: function({ node, plugin, error, mapPane, domtoimageOptions }) {
// Solutions:
// decrease size of map
// or decrease zoom level
// or remove elements with big distanses
// and after that return image in Promise - plugin._getPixelDataOfNormalMap
return plugin._getPixelDataOfNormalMap(domtoimageOptions)
}
})`)
return Promise.reject(error)
}
},
onAdd () {
this._container = L.DomUtil.create(
'div',
'leaflet-control-simpleMapScreenshoter'
)
this._link = null
this._screenState = {
status: STATUS_READY,
promise: null
}
if (this.options.hidden === false) {
this._addScreenBtn()
}
this._onUserStartInteractWithMap = this._onUserStartInteractWithMap.bind(this)
this._onUserEndInteractWithMap = this._onUserEndInteractWithMap.bind(this)
this._map.on('zoomstart', this._onUserStartInteractWithMap)
this._map.on('move', this._onUserStartInteractWithMap)
this._map.on('zoomend', this._onUserEndInteractWithMap)
this._map.on('moveend', this._onUserEndInteractWithMap)
return this._container
},
/**
* @param format {string} blob or png
* @param sreenOptions {Object}
* @returns {Promise<Blob>|Promise<Base64>} Blob or png image
*/
takeScreen (format = 'blob', sreenOptions = {}) {
const options = {}
for (let opt in this.options) {
if (sreenOptions.hasOwnProperty(opt)) {
options[opt] = sreenOptions[opt]
} else {
options[opt] = this.options[opt]
}
}
if (this._screenState.status === STATUS_PENDING) {
return this._screenState.promise
}
this._map.fire('simpleMapScreenshoter.takeScreen')
this._screenState.status = STATUS_PENDING
this._setElementsVisible(false)
this._screenState.promise = this._waitEndOfInteractions()
.then(() => this._getPixelData(options))
.then(pixels => {
this._setElementsVisible(true)
return this._toCanvas(pixels, options)
})
.then(canvas => {
if (format === 'image') {
return this._canvasToImage(canvas, options)
} else if (format === 'canvas') {
return canvas
}
return this._canvasToBlob(canvas, options)
})
.then(image => {
this._screenState.status = STATUS_READY
this._map.fire('simpleMapScreenshoter.done')
return image
}).catch(e => {
this._setElementsVisible(true)
this._screenState.status = STATUS_READY
this._map.fire('simpleMapScreenshoter.error', {e})
return Promise.reject(e)
})
return this._screenState.promise
},
/**
* @param isVisible
* @private
*/
_setElementsVisible (isVisible = false) {
this.options.hideElementsWithSelectors.forEach(selector => {
const els = this._map._container.querySelectorAll(selector)
for (let el of els) {
el.style.opacity = isVisible === false
? 0
: 1
}
})
},
/**
* @param canvas
* @param mimeType
* @returns {Promise<Base64>}
* @private
*/
_canvasToImage (canvas, {mimeType}) {
const image = canvas.toDataURL(mimeType)
if (image.indexOf('base64') === -1) {
return Promise.reject(new Error('Base64 image generation error'))
}
return Promise.resolve(image)
},
/**
* @param canvas {Canvas}
* @returns {Promise<Blob>}
* @private
*/
_canvasToBlob (canvas, {mimeType}) {
return new Promise((resolve, reject) => {
canvas.toBlob(blob => {
resolve(blob)
}, mimeType)
})
},
/**
* @param pixels {Uint8Array}
* @param options {Object}
* @returns {Promise<Canvas>}
* @private
*/
_toCanvas (pixels, options) {
let {captionOffset, caption, captionFontSize, captionFont, captionColor, captionBgColor} = options
let {screenHeight, screenWidth} = this._node
let canvas = document.createElement('canvas')
canvas.width = screenWidth
canvas.height = screenHeight
let ctx = canvas.getContext('2d')
let imageData = ctx.createImageData(screenWidth, screenHeight)
for (let i = 0; i < pixels.length; ++i) {
imageData.data[i] = pixels[i]
}
ctx.putImageData(imageData, 0, 0)
imageData = null
let transformedCanvas = document.createElement('canvas')
let transformedCanvasCtx = transformedCanvas.getContext('2d')
let minY = 0
let minX = 0
let height = screenHeight
let width = screenWidth
// crop blank opacity from image borders
if (this.options.cropImageByInnerWH === true) {
let pixelAtXYOffset = 0
let debugY = {}
let debugX = {}
let emptyYLine = []
let emptyCountOnHorizontal = 0
for (let y = 0; y < screenHeight; ++y) {
emptyCountOnHorizontal = 0
for (let x = 0; x < screenWidth; ++x) {
pixelAtXYOffset = (4 * y * screenWidth) + (4 * x)
// find opacity = 0 on horizontal
if (pixels[pixelAtXYOffset + 4] === 0) {
emptyCountOnHorizontal++
}
/* if (y === Math.round(screenHeight / 2)) {
debugY[x] = pixels.slice(pixelAtXYOffset, pixelAtXYOffset + 4)
} */
}
// save empty horizontal
if (emptyCountOnHorizontal === screenWidth) {
emptyYLine.push(y)
}
}
const minMaxY = this._getMinAndMaxOnValuesBreak(emptyYLine)
minY = minMaxY.min
let maxX = screenWidth
let maxY = minMaxY.max
let emptyXLine = []
let emptyCountOnVertical = 0
for (let x = minX; x < maxX; ++x) {
emptyCountOnVertical = 0
for (let y = 0; y < screenHeight; ++y) {
pixelAtXYOffset = (4 * y * screenWidth) + (4 * x)
// find opacity = 0 on vertical
if (pixels[pixelAtXYOffset + 4] === 0) {
emptyCountOnVertical++
}
/* if (x === screenWidth - 5) {
debugX[y] = pixels.slice(pixelAtXYOffset, pixelAtXYOffset + 4)
} */
}
// save empty vertical
if (emptyCountOnVertical === screenHeight) {
emptyXLine.push(x)
}
}
const minMaxX = this._getMinAndMaxOnValuesBreak(emptyXLine)
minX = minMaxX.min
maxX = minMaxX.max
if ((minX === 0 && maxX === 0) || maxX === null) {
maxX = screenWidth
}
if ((minY === 0 && maxY === 0) || maxY === null) {
maxY = screenHeight
}
if (this.options.debugMode === true) {
console.log('emptyYLine', emptyYLine)
console.log('minMaxY', minMaxY)
console.log('emptyXLine', emptyXLine)
console.log('minMaxX', minMaxX)
console.log('debugX', debugX)
console.log('debugY', debugY)
}
// if w/h changed, scale inner
if (minY !== 0 || maxY !== screenHeight || minX !== 0 || maxX !== screenWidth) {
minY = minY + this.options.onCropBorderSize
maxY = maxY - this.options.onCropBorderSize
minX = minX + this.options.onCropBorderSize
maxX = maxX - this.options.onCropBorderSize
}
height = maxY - minY
width = maxX - minX
transformedCanvas.width = width
transformedCanvas.height = height
} else {
transformedCanvas.width = width
transformedCanvas.height = height
}
let captionTxt = null
if (caption) {
captionTxt = typeof caption === 'function'
? caption.call(this)
: caption
}
// increase size of canvas for write caption
if (captionTxt !== null) {
transformedCanvas.height = transformedCanvas.height + (captionOffset + captionFontSize + captionOffset)
// set bg color
transformedCanvasCtx.beginPath()
transformedCanvasCtx.rect(0, 0, transformedCanvas.width, transformedCanvas.height)
transformedCanvasCtx.fillStyle = captionBgColor
transformedCanvasCtx.fill()
transformedCanvasCtx.save()
}
transformedCanvasCtx.drawImage(canvas, minX, minY, width, height, 0, 0, width, height)
// write caption text
if (captionTxt !== null) {
transformedCanvasCtx.font = `${captionFontSize}px ${captionFont}`
transformedCanvasCtx.fillStyle = captionColor
transformedCanvasCtx.fillText(captionTxt, captionOffset, height + captionOffset + captionFontSize)
}
return Promise.resolve(transformedCanvas)
},
/**
* find break in array [0,1,2,3,40,41,42]
* we get 3 and 40
* @param arr {Array}
* @returns {{min: number, max: number}}
* @private
*/
_getMinAndMaxOnValuesBreak (arr) {
let min = 0
let max = 0
let hasBreak = false
for (let i = 1; i < arr.length; i++) {
if (arr[i] - 1 === arr[i - 1]) {
min = arr[i]
} else {
max = arr[i]
hasBreak = true
break
}
}
// if on start no opacity, opacity may be exist on end
if (hasBreak === false && arr[0] > 1) {
min = 0
max = arr[0]
// opaciy at start
} else if (hasBreak === false) {
min = arr[arr.length - 1] || 0
max = null
}
return {min, max}
},
/**
* @param domtoimageOptions
* @returns {Promise<Uint8Array>}
* @private
*/
_getPixelData ({domtoimageOptions = {}}) {
/**
* 1) we try try to get full map size screen, but we have problem of big canvas:
* https://stackoverflow.com/questions/6081483/maximum-size-of-a-canvas-element
* ie mobile - 4000
* ie - 8000
* chrome - 16000
* firefox - 32000
*
* 2) on error, we can create screen with hide not visible elements by css
* sometimes it not work, need solution
*/
return this._getPixelDataOfNormalMap(domtoimageOptions).catch(e => {
console.warn('May be map size very big on that zoom level, we have error:', e.toString())
console.warn('You can manually hide map elements with large distances between them for fix that warn')
return this._getPixelDataOfBigMap(domtoimageOptions)
})
},
_getPixelDataOfNormalMap (domtoimageOptions = {}) {
const node = this._map.getContainer()
this._node = {
node,
screenHeight: node.scrollHeight,
screenWidth: node.scrollWidth
}
return domtoimage.toPixelData(node, domtoimageOptions)
},
_getPixelDataOfBigMap (domtoimageOptions = {}) {
const node = this._map.getContainer()
// fix: https://github.com/grinat/leaflet-simple-map-screenshoter/issues/7
// fix memory out error if map scrollHeight and scrollWidth very big
const mapPane = this._map.getPane('mapPane')
const increaser = 2 // <-- when window resize, map resize with translate option, we need what for prevent gray on borders
mapPane.style.width = `${node.clientWidth * increaser}px`
mapPane.style.height = `${node.clientHeight * increaser}px`
mapPane.style.overflow = 'hidden'
const restoreMapPane = () => {
mapPane.style.width = 'initial'
mapPane.style.height = 'initial'
mapPane.style.overflow = 'initial'
}
this._node = {
node,
screenHeight: node.scrollHeight,
screenWidth: node.scrollWidth
}
return domtoimage.toPixelData(node, domtoimageOptions).then(r => {
restoreMapPane()
return r
}).catch(e => {
restoreMapPane()
// 4000 - max canvas size on ie mobile
if (node.scrollHeight >= 4000 || node.scrollWidth >= 4000) {
return this.options.onPixelDataFail({
plugin: this,
node,
mapPane,
error: e,
domtoimageOptions
})
}
return Promise.reject(e)
})
},
/**
* @private
*/
_addScreenBtn () {
this._link = L.DomUtil.create(
'a',
'leaflet-control-simpleMapScreenshoter-btn',
this._container
)
this._addCss()
L.DomEvent.addListener(this._link, 'click', this._onScreenBtn, this)
L.DomEvent.disableClickPropagation(this._link)
},
/**
* @private
*/
_addCss () {
let css = `
.leaflet-control-simpleMapScreenshoter{
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
.leaflet-control-simpleMapScreenshoter a{
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 30px;
height: 30px;
line-height: 30px;
display: block;
text-align: center;
text-decoration: none;
color: black;
overflow: hidden;
border-radius: 2px;
background-image: url('${this.options.iconUrl}');
background-position: 46% 41%;
background-repeat: no-repeat;
background-size: 63%;
}
.leaflet-control-simpleMapScreenshoter a:hover{
cursor: pointer;
}
`
let head = document.head || document.getElementsByTagName('head')[0]
let style = document.createElement('style')
style.appendChild(
document.createTextNode(
css
)
)
head.appendChild(style)
},
/**
* @private
*/
_onScreenBtn () {
this._map.fire('simpleMapScreenshoter.click')
if (this.options.preventDownload) return
this.takeScreen().then(blob => {
const screenName = typeof this.options.screenName === 'function'
? this.options.screenName.call(this)
: this.options.screenName
fileSaver.saveAs(blob, `${screenName}.png`)
}).catch(e => {
this._map.fire('simpleMapScreenshoter.error', {e})
})
},
_onUserStartInteractWithMap () {
this._interaction = true
},
_onUserEndInteractWithMap () {
this._interaction = false
},
_waitEndOfInteractions () {
return new Promise(resolve => {
const interval = setInterval(() => {
if (!this._interaction) {
resolve()
clearInterval(interval)
}
}, 100)
})
}
})
export default SimpleMapScreenshoter