Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/GameMap.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();

Expand Down
221 changes: 221 additions & 0 deletions src/Lib/LeafletPlugins.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>} 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 = '<i class="far fa-camera"></i>';
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);
};
64 changes: 64 additions & 0 deletions src/SCIM.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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();