diff --git a/src/GameMap.js b/src/GameMap.js index f381aae3..b1920383 100644 --- a/src/GameMap.js +++ b/src/GameMap.js @@ -233,7 +233,8 @@ export default class GameMap noWrap : true, bounds : this.getBounds(), maxZoom : (this.maxTileZoom + 4), - maxNativeZoom : this.maxTileZoom + maxNativeZoom : this.maxTileZoom, + crossOrigin : true, // We need this in order to be able to render tiles to a canvas. Otherwise, we'd need to re-fetch all the tiles. }; this.baseLayer = 'gameLayer'; @@ -244,6 +245,10 @@ export default class GameMap this.leafletMap.setMaxBounds(this.getBounds()); this.leafletMap.fitBounds(this.getBounds()); + // Add a button to export the current viewport as an image and then download it. + this.exportControl = L.control.exportControl({}); + this.leafletMap.addControl(this.exportControl); + // Trigger initial hash to load previous layers... this.formatHash(); diff --git a/src/Lib/LeafletPlugins.js b/src/Lib/LeafletPlugins.js index d1bd001d..94df66a6 100644 --- a/src/Lib/LeafletPlugins.js +++ b/src/Lib/LeafletPlugins.js @@ -113,4 +113,225 @@ L.Control.SliderControl = L.Control.extend({ L.control.sliderControl = function(options) { return new L.Control.SliderControl(options); +}; + +L.TileLayer.include({ + /** + * Render the tile layer to a canvas with the same dimensions as the map's viewport. + * @param {Function} callback A callback called when rendering to the canvas is complete. Standard callback of `Function(err?, canvas?)` signature + * @returns {void} + */ + renderToCanvas: function(callback) { + // Defer until we're done loading tiles + if (this._loading) { + return this.once('load', () => { + this.renderToCanvas(callback); + }); + } + + const map = this._map; + if (!map) { + return callback(new Error('Unable to render to canvas: No map!')); + } + + if (this._tileZoom === undefined) { + return callback(new Error('Unable to render to canvas: Zoom out of range!'));; + } + + // There's a lot of information we need from the map to understand where a tile should be rendered + // This approach cheats a little bit: instead of trying to figure out which tiles need to be rendered, + // we assume they all already are loaded and rendered correctly. This lets us sidestep a handful of steps + // So, starting from the list of loaded and active tiles, we need to create a canvas with the same dimensions + // as the map viewport, and then render into each each tile. + // This is complicated by the way `leaflet` works: not only do we have to understand where a tile exists + // with respect to the layer, we also have to understand where that layer exists with respect to the map + // and whether or not that layer has had any zoom transformations applied. + // Once we have all that information, we can apply the necessary transformations to the tile to place + // it in the correct location. + const zoom = this._clampZoom(map.getZoom()); + const layerScale = map.getZoomScale(zoom, this._tileZoom); + const level = this._levels[this._tileZoom]; + const pixelOrigin = map._getNewPixelOrigin(map.getCenter(), zoom); + const layerTranslate = level.origin.multiplyBy(layerScale).subtract(pixelOrigin); + const mapTranslate = map.layerPointToContainerPoint([0, 0]); + const tileSize = this.getTileSize(); + const scaledSize = tileSize.multiplyBy(layerScale); + const canvasDimensions = map.getSize(); + + // This canvas should be the same size as the map's viewport: + const canvas = document.createElement('canvas'); + canvas.width = canvasDimensions.x; + canvas.height = canvasDimensions.y; + const context = canvas.getContext('2d'); + + // render all the tiles into the canvas: + for (const key in this._tiles) { + const tile = this._tiles[key]; + if (!tile.current) { + console.warn(`Missing tile ${tile.coords}`, tile); + continue; + } else { + // We can get the tile's position with respect to the layer with `_getTilePos`, but + // we also need to scale/translate it according to the tile container's transformations, + // and then the map pane's translation + // We take advantage of the 2d context's ability to do the scaling for us. + // Note: this may not be precise: see the hack at the end of `GameMap.js` + const offset = this._getTilePos(tile.coords).multiplyBy(layerScale).add(layerTranslate).add(mapTranslate); + context.drawImage(tile.el, 0, 0, tileSize.x, tileSize.y, offset.x, offset.y, scaledSize.x, scaledSize.y); + } + } + + return callback(null, canvas); + } +}); + +/** + * Iterate over an array in an asynchronous way. The item callback will be provided with a `next` callback, to be called when + * time to move on to the next item. Call `next` with an error to abort iteration early. `done` will receive this error. + * @param {Array} arr The array to iterate over + * @param {Function} callback The callback to execute on each item. Called with two arguments: the current item, and `next`, to be called when done + * @param {Function} done A callback to be called at the end of iteration. Receives two arg + * @returns {void} + */ +const eachAsync = (arr, callback, done) => { + let i = 0; + const len = arr.length; + let next = (err) => { + if (err) { + return done(err); + } + + if (i < len) { + callback(arr[i], () => { + i++; + // We use requestAnimationFrame to break out of the call stack. Probably overkill here. + requestAnimationFrame(() => next()); + }); + } else { + done(); + } + }; + + return next(); +} + +L.Map.include({ + /** + * If we have an overlay pane, render it to a canvas + * @returns {null|HTMLCanvasElement} + */ + _renderOverlay: function() { + if (!this._panes) { + return null; + } + + const overlay = this._panes.overlayPane.getElementsByTagName('canvas').item(0); + if (!overlay) { + return null; + } + + // This is taken from the `leaflet-image` plugin, with some small modifications + const dimensions = this.getSize(); + const bounds = this.getPixelBounds(), + origin = this.getPixelOrigin(), + canvas = document.createElement('canvas'); + canvas.width = dimensions.x; + canvas.height = dimensions.y; + var ctx = canvas.getContext('2d'); + var pos = L.DomUtil.getPosition(overlay).subtract(bounds.min).add(origin); + try { + ctx.drawImage(overlay, pos.x, pos.y, canvas.width - (pos.x * 2), canvas.height - (pos.y * 2)); + return canvas; + } catch(e) { + console.error('Element could not be drawn on canvas', canvas); // eslint-disable-line no-console + } + return null; + }, + + /** + * Render the entire map to a canvas. This canvas can then be saved as an image with something like + * FileSaver.js + * @param {Function} callback Callback that receives (err?, canvas?) when done generating the image + */ + renderToCanvas: function(callback) { + const layers = []; + // Right now, we only support rendering `TileLayer` layers and the overlay pane. + const tileLayers = Object.values(this._layers).filter(l => l instanceof L.TileLayer); + eachAsync(tileLayers, (layer, next) => { + // We'll render each layer to a separate canvas, then composite them at the end. + layer.renderToCanvas((err, canvas) => { + if (err) { + return next(err); + } + layers.push(canvas); + next(); + }); + }, (err) => { + if (err) { + return callback(err); + } + + // If we have an overlay, let's render it out to a canvas, as well. + const overlay = this._renderOverlay(); + // if we have an overlay layer, draw it last + if (overlay) { + layers.push(overlay); + } + + const dimensions = this.getSize(); + const finalCanvas = document.createElement('canvas'); + finalCanvas.width = dimensions.x; + finalCanvas.height = dimensions.y; + const ctx = finalCanvas.getContext('2d'); + + // composite the layers in order + layers.forEach((layer) => { + ctx.drawImage(layer, 0, 0); + }); + + callback(null, finalCanvas); + }); + } +}); + +L.Control.ExportControl = L.Control.extend({ + options: { + position: 'topleft', + }, + + initialize: function(options) { + L.Util.setOptions(this, options); + }, + + onAdd: function(map) { + this.options.map = map; + const className = 'leaflet-control-zoom leaflet-bar'; + const container = L.DomUtil.create('div', className); + + const button = L.DomUtil.create('a', 'leaflet-control-selection leaflet-bar-part', container); + button.innerHTML = ''; + button.href = '#'; + button.title = 'Export current view as image'; + button.dataset.hover = 'tooltip'; + button.dataset.placement = 'right'; + + L.DomEvent + .on(button, 'click', L.DomEvent.stopPropagation) + .on(button, 'click', L.DomEvent.preventDefault) + .on(button, 'click', this._exportImage, this) + .on(button, 'dbclick', L.DomEvent.stopPropagation); + + return container; + }, + + onRemove: function(){}, + + _exportImage: function() { + // This seems janky, since this control is added via `GameMap`... + window.SCIM.exportImage(); + } +}); + +L.control.exportControl = function(options) { + return new L.Control.ExportControl(options); }; \ No newline at end of file diff --git a/src/SCIM.js b/src/SCIM.js index 4329e671..60a2ae8c 100644 --- a/src/SCIM.js +++ b/src/SCIM.js @@ -5,6 +5,7 @@ import Translate from './Translate.js'; import BaseLayout_Modal from './BaseLayout/Modal.js'; import Lib_LeafletPlugins from './Lib/LeafletPlugins.js'; +import saveAs from './Lib/FileSaver.js'; export default class SCIM { @@ -226,5 +227,68 @@ export default class SCIM return true; }; + + /** + * Export the current viewport as a png. If there is a save game loaded, this png will have the same name as the save game file. + * If not, it will be named `export.png` + * @async + * @returns {void} + */ + exportImage() { + // no map? + if (!this.map) { + return null; + } + let imageName = 'export.png'; + if (this.baseLayout && this.baseLayout.saveGameParser) { + imageName = this.baseLayout.saveGameParser.fileName.replace(/\.sav$/g, '.png'); + } + + this.map.leafletMap.renderToCanvas((err, canvas) => { + if (err) { + return console.error(err); + } + + if (canvas.toBlob) { + return canvas.toBlob((blob) => { + if (!blob) { + console.error('Failed to render to canvas for some reason...'); + } else { + saveAs(blob, imageName); + } + }); + } else { + const dataURL = canvas.toDataURL(); + if (!dataURL) { + console.error('Could not get data url from canvas!'); + } + + const BASE64_MARKER = ';base64,'; + let blob; + if (dataURL.indexOf(BASE64_MARKER) == -1) { + const parts = dataURL.split(','); + const contentType = parts[0].split(':')[1]; + const raw = decodeURIComponent(parts[1]); + + blob = new Blob([raw], {type: contentType}); + } else { + const parts = dataURL.split(BASE64_MARKER); + const contentType = parts[0].split(':')[1]; + const raw = window.atob(parts[1]); + const rawLength = raw.length; + + const uInt8Array = new Uint8Array(rawLength); + + for (let i = 0; i < rawLength; ++i) { + uInt8Array[i] = raw.charCodeAt(i); + } + + blob = new Blob([uInt8Array], {type: contentType}); + } + + saveAs(blob, imageName); + } + }); + } } window.SCIM = new SCIM(); \ No newline at end of file