From 19da6af37ba8dd2c9561e08e06fd947055977019 Mon Sep 17 00:00:00 2001 From: DeviateFish Date: Sat, 21 May 2022 19:49:41 -0700 Subject: [PATCH 1/2] Add a button to the minimap to export the current view as an image - Extends `TileLayer` to provide the ability to render to a canvas - Extends `Map` to composite any tile layers and overlays as canvases - Add a control that uses `map.exportToCanvas` to get an canvas representation of the current viewport. This will save as `export.png` when no save is loaded, or as `.png` if there is a save - This is a little janky at the moment, and could probably be improved somewhat. The only thing that has knowledge of both the map and the save game is the global `SCIM` object, which seems strange to access from a control installed by `GameMap` --- src/GameMap.js | 6 +- src/Lib/LeafletPlugins.js | 180 ++++++++++++++++++++++++++++++++++++++ src/SCIM.js | 60 +++++++++++++ 3 files changed, 245 insertions(+), 1 deletion(-) diff --git a/src/GameMap.js b/src/GameMap.js index f381aae3..3a38517c 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, }; this.baseLayer = 'gameLayer'; @@ -244,6 +245,9 @@ export default class GameMap this.leafletMap.setMaxBounds(this.getBounds()); this.leafletMap.fitBounds(this.getBounds()); + 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..47e85250 100644 --- a/src/Lib/LeafletPlugins.js +++ b/src/Lib/LeafletPlugins.js @@ -113,4 +113,184 @@ L.Control.SliderControl = L.Control.extend({ L.control.sliderControl = function(options) { return new L.Control.SliderControl(options); +}; + +L.TileLayer.include({ + 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!'));; + } + + 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 is the size of all the tiles that are currently visible, partial or otherwise: + 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 { + 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); + } +}); + +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++; + requestAnimationFrame(() => next()); + }); + } else { + done(); + } + }; + + return next(); +} + +L.Map.include({ + _renderOverlay: function() { + if (!this._panes) { + return null; + } + + const overlay = this._panes.overlayPane.getElementsByTagName('canvas').item(0); + if (!overlay) { + return null; + } + + 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; + }, + + renderToCanvas: function(callback) { + const layers = []; + const tileLayers = Object.values(this._layers).filter(l => l instanceof L.TileLayer); + eachAsync(tileLayers, (layer, next) => { + layer.renderToCanvas((err, canvas) => { + if (err) { + return next(err); + } + layers.push(canvas); + next(); + }); + }, (err) => { + if (err) { + return callback(err); + } + + 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() { + 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..1a166ffd 100644 --- a/src/SCIM.js +++ b/src/SCIM.js @@ -1,3 +1,4 @@ +/* global L */ import BaseLayout from './BaseLayout.js'; import GameMap from './GameMap.js'; import SaveParser from './SaveParser.js'; @@ -5,6 +6,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 +228,63 @@ export default class SCIM return true; }; + + // Export map as image + 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 From 3b64a4ab1f3f63e4d354673e99ebf418ca0c3887 Mon Sep 17 00:00:00 2001 From: DeviateFish Date: Sat, 21 May 2022 20:44:44 -0700 Subject: [PATCH 2/2] Documentation and cleanup pass. - Make styles internally consistent - Add some documentation and JSDoc for methods - Add some attribution for the rendering of the `overlayPane` --- src/GameMap.js | 3 ++- src/Lib/LeafletPlugins.js | 51 +++++++++++++++++++++++++++++++++++---- src/SCIM.js | 8 ++++-- 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/src/GameMap.js b/src/GameMap.js index 3a38517c..b1920383 100644 --- a/src/GameMap.js +++ b/src/GameMap.js @@ -234,7 +234,7 @@ export default class GameMap bounds : this.getBounds(), maxZoom : (this.maxTileZoom + 4), maxNativeZoom : this.maxTileZoom, - crossOrigin : true, + 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'; @@ -245,6 +245,7 @@ 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); diff --git a/src/Lib/LeafletPlugins.js b/src/Lib/LeafletPlugins.js index 47e85250..94df66a6 100644 --- a/src/Lib/LeafletPlugins.js +++ b/src/Lib/LeafletPlugins.js @@ -116,6 +116,11 @@ L.control.sliderControl = function(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) { @@ -133,6 +138,16 @@ L.TileLayer.include({ 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]; @@ -143,7 +158,7 @@ L.TileLayer.include({ const scaledSize = tileSize.multiplyBy(layerScale); const canvasDimensions = map.getSize(); - // This canvas is the size of all the tiles that are currently visible, partial or otherwise: + // 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; @@ -156,6 +171,11 @@ L.TileLayer.include({ 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); } @@ -165,6 +185,14 @@ L.TileLayer.include({ } }); +/** + * 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; @@ -176,6 +204,7 @@ const eachAsync = (arr, callback, done) => { if (i < len) { callback(arr[i], () => { i++; + // We use requestAnimationFrame to break out of the call stack. Probably overkill here. requestAnimationFrame(() => next()); }); } else { @@ -187,6 +216,10 @@ const eachAsync = (arr, callback, done) => { } L.Map.include({ + /** + * If we have an overlay pane, render it to a canvas + * @returns {null|HTMLCanvasElement} + */ _renderOverlay: function() { if (!this._panes) { return null; @@ -197,6 +230,7 @@ L.Map.include({ 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(), @@ -214,10 +248,17 @@ L.Map.include({ 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); @@ -230,6 +271,7 @@ L.Map.include({ 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) { @@ -257,13 +299,11 @@ L.Control.ExportControl = L.Control.extend({ position: 'topleft', }, - initialize: function(options) - { + initialize: function(options) { L.Util.setOptions(this, options); }, - onAdd: function(map) - { + onAdd: function(map) { this.options.map = map; const className = 'leaflet-control-zoom leaflet-bar'; const container = L.DomUtil.create('div', className); @@ -287,6 +327,7 @@ L.Control.ExportControl = L.Control.extend({ onRemove: function(){}, _exportImage: function() { + // This seems janky, since this control is added via `GameMap`... window.SCIM.exportImage(); } }); diff --git a/src/SCIM.js b/src/SCIM.js index 1a166ffd..60a2ae8c 100644 --- a/src/SCIM.js +++ b/src/SCIM.js @@ -1,4 +1,3 @@ -/* global L */ import BaseLayout from './BaseLayout.js'; import GameMap from './GameMap.js'; import SaveParser from './SaveParser.js'; @@ -229,7 +228,12 @@ export default class SCIM return true; }; - // Export map as image + /** + * 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) {