I got this as part of some interview assignment. After some head scratching I think I have done quite well as the result can be seen here:
After transformation(10x10 tile):
More crisp picture with smaller tile size:
So, the requirement were:
- Not to use any external library.
- Only front end code.
- Should be a production ready.
- Use HTML5 features where appropriate.
- Make the code modular.
- Make the code testable.
Brief problem description:
- A user selects a local image file.
- The app loads that image, divides the image into tiles, computes the average color of each tile, fetches a tile from the server for that color, and composites the results into a photomosaic of the original image.
- The composited photomosaic should be displayed according to the following constraints:
- tiles should be rendered a complete row at a time (a user should never see a row with some completed tiles and some incomplete)
- the mosaic should be rendered from the top row to the bottom row.
The client app should make effective use of parallelism and asynchrony.
- tiles should be rendered a complete row at a time (a user should never see a row with some completed tiles and some incomplete)
- the mosaic should be rendered from the top row to the bottom row.
Code:
client.js
'use strict';
/** @fileoverview */
(function(window, document, app) {
var DOMURL = window.URL || window.webkitURL;
var SVG_URL = '/color/';
function Tile(pixelData, x, y) {
this.hex = Utils.rgbToHex(pixelData);
this.x = x * TILE_WIDTH;
this.y = y * TILE_HEIGHT;
};
/**
* Draws a offscreen canvas to get averaged rgb per tile.
* see http://stackoverflow.com/a/17862644/4260745
*/
function getAverageRgb(image) {
var canvas = document.createElement('canvas');
var ctx = canvas.getContext('2d');
canvas.width = image.width / TILE_WIDTH;
canvas.height = image.height / TILE_HEIGHT;
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
return ctx;
};
function getTilesMetaData(sourceImage) {
var res = [];
var context = getAverageRgb(sourceImage);
var NUM_TILES_X = sourceImage.width / TILE_WIDTH;
var NUM_TILES_Y = sourceImage.height / TILE_HEIGHT;
var data = context.getImageData(0, 0, NUM_TILES_X, NUM_TILES_Y).data;
var i = 0;
for (var row = 0; row < NUM_TILES_Y; row++) {
for (var col = 0; col < NUM_TILES_X; col++) {
res.push(new Tile(data.subarray(i * 4, i * 4 + 3), col, row));
i++;
}
}
return res;
};
/**
* Draws PhotoMosaic on screen.
*/
function drawMosiac(image) {
var canvas = document.createElement('canvas');
var ctx = canvas.getContext('2d');
canvas.width = image.width;
canvas.height = image.height;
var chunkSize = image.width / TILE_WIDTH;
var tilesMetadata = getTilesMetaData(image);
var chunks = tilesMetadata.splice(0, chunkSize);
var promises = [];
while (chunks.length > 0) {
promises.push(
Promise.all(chunks.map(function(data) { return getSvg(data); })));
chunks = tilesMetadata.splice(0, chunkSize);
}
function renderRow(promises) {
// Base case.
if (promises.length === 0) return;
promises.shift().then(function(results) {
results.forEach(function(result) {
renderSVGTile(ctx, result.svg, {x: result.x, y: result.y});
});
// Resolve the remaining promises.
renderRow(promises);
});
};
renderRow(promises);
return canvas;
};
function createSVGUrl(svg) {
var svgBlob = new Blob([svg], {type: 'image/svg+xml;charset=utf-8'});
return DOMURL.createObjectURL(svgBlob);
};
function renderSVGTile(ctx, svg, pos) {
var img = new Image();
var url = createSVGUrl(svg);
img.onload = function() {
try {
ctx.drawImage(img, pos.x, pos.y);
ctx.imageSmoothingEnabled = false;
ctx.mozImageSmoothingEnabled = false;
DOMURL.revokeObjectURL(url);
} catch(e) {
throw new Error('Could not render image' + e);
}
};
img.src = url;
};
function getSvg(data) {
return Utils.httpGet(SVG_URL + data.hex)
.then(function(svg) { return {svg: svg, x: data.x, y: data.y}; })
.catch(function(error) {
// One or more promises were rejected
console.log(error);
});
};
function handleFileUpload(callback) {
var img = new Image();
img.src = window.URL.createObjectURL(this);
// img.crossOrigin = 'anonymous';
img.onload = function() { callback(this); }
};
var Utils = {
componentToHex: function componentToHex(c) {
var hex = c.toString(16);
return hex.length == 1 ? '0' + hex : hex;
},
rgbToHex: function rgbToHex(rgb) {
return this.componentToHex(rgb[0]) + this.componentToHex(rgb[1]) +
this.componentToHex(rgb[2]);
},
httpGet: function httpGet(url) {
// Return a new promise.
return new Promise(function(resolve, reject) {
// Do the usual XHR stuff
var req = new XMLHttpRequest();
req.open('GET', url);
req.onload = function() {
// This is called even on 404 etc
// so check the status
if (req.status == 200) {
// Resolve the promise with the response text
resolve(req.response);
} else {
// Otherwise reject with the status text
// which will hopefully be a meaningful error
reject(Error(req.statusText));
}
};
// Handle network errors
req.onerror = function() { reject(Error('Network Error')); };
// Make the request
req.send();
});
}
};
app.run = function run() {
var inputElement = document.getElementById('input');
var ul = document.getElementById('image-list');
var li = document.createElement('li');
ul.appendChild(li);
inputElement.addEventListener('change', function() {
handleFileUpload.call(
this.files[0], function(image) {
var canvas = drawMosiac(image);
li.appendChild(canvas);
});
}, false);
};
})(window, document, window.app || (window.app = {}));
mosaic.js
// Constants shared between client and server.
var TILE_WIDTH = 8;
var TILE_HEIGHT = 8;
var exports = exports || null;
if (exports) {
exports.TILE_WIDTH = TILE_WIDTH;
exports.TILE_HEIGHT = TILE_HEIGHT;
}
index.html
<!DOCTYPE html>
<html>
<head>
<script type="text/javascript" src="js/mosaic.js"></script>
<script type="text/javascript" src="js/client.js"></script>
<title>Mosaic</title>
<style>
.container {
margin: 0 auto;
width: 50%;
}
.container ul {
list-style: none;
margin-left: 0;
}
</style>
</head>
<body>
<div class="container">
<ul id="image-list">
</ul>
<input id="input" type="file" accept="image/*">
</div>
<script>
(function(app) {
document.addEventListener('DOMContentLoaded', function() {
app.run();
});
})(window.app || (window.app = {}));
</script>
</body>
</html>
Note:
Only client.js
and index.html
are written by me.
Promise
an external library, prohibited by the rules? \$\endgroup\$