OK, I've re-factored my earlier code as a jQuery plugin named "canvasLens". It accepts a bunch of options to control things like image src, lens size and border color. You can even choose between two different lens effects, "fisheye" or "scaledSquare".
I've tried to make it as self-explanatory as possible with a header block and plenty of other comments.
/*
* Copyright (c) 2014 Roamer-1888
* "canvasLens"
* a jQuery plugin for a lens effect on one or more HTML5 canvases
* by Roamer-1888, 2014-11-09
* http://stackoverflow.com/users/3478010/roamer-1888
*
* Written in response to aa question by Muhammad Umer, here
* http://stackoverflow.com/questions/26793321/
*
* Invoke on a canvas element as follows
* $("#canvas").lens({
* imgSrc: 'path/to/image',
* imgCrossOrigin: '' | 'anonymous' | 'use-credentials', //[1]
* drawImageCoords: [ //[2]
* 0, 0, //(sx,st) Source image sub-rectangle Left,Top.
* 1350, 788, //(sw/sh) Source image sub-rectangle Width,Height.
* 0, 0, //(dx/dy) Destination Left,Top.
* 800, 467 //(dw/dh) Destination image sub-rectangle Width,Height.
* ],
* effect: 'fisheye' | 'scaledSquare',
* scale: 2 //currently affects only 'scaledSquare'
* size: 100, //diameter/side-length of the lens in pixels
* hideCursor: true | false,
* border: [0, 0, 0, 255] //[r,g,b,alpha] (base-10) | 'none'
* });
*
* Demo: http://jsfiddle.net/7z6by3o3/1/
*
* Further reading :
* [1] imgCrossOrigin -
* https://developer.mozilla.org/en-US/docs/Web/HTML/CORS_settings_attributes
* [2] drawImageCoords -
* https://developer.mozilla.org/en/docs/Web/API/CanvasRenderingContext2D
*
* Licence: MIT - http://en.wikipedia.org/wiki/MIT_License
*
* Please keep this header block intact, with amendments
* to reflect any changes made to the code.
*
*/
(function($){
// *******************************
// ***** Start: Private vars *****
// *******************************
var pluginName = 'canvasLens';
// *****************************
// ***** Fin: Private vars *****
// *****************************
// **********************************
// ***** Start: Private Methods *****
// **********************************
// Note that in all private methods,
// `this` is the canvas on which
// the plugin is invoked.
// Most private methods are called
// with `methodName.call(this)`.
// **********************************
function animate() {
var data = $(this).data(pluginName);
if(data) {
draw.call(this);
requestAnimationFrame(animate.bind(this));
}
}
function draw() {
var data = $(this).data(pluginName);
data.ctx.drawImage(data.m_can, 0, 0);
if(data.showLens) {
if(data.settings.effect == 'scaledSquare') {
scaledSquare.call(this);
} else {
fisheye.call(this);
}
}
}
function putBg() {
var data = $(this).data(pluginName);
data.m_ctx.drawImage.apply(data.m_ctx, [data.img].concat(data.settings.drawImageCoords));
}
function scaledSquare() {
var data = $(this).data(pluginName),
xt = data.settings.scale,
h = data.settings.size;
data.ctx.drawImage(data.m_can,
data.mouse.x - h/xt/2, data.mouse.y - h/xt/2, //sx,st Source image sub-rectangle Left,Top coordinates.
h/xt, h/xt, //sw/sh Source image sub-rectangle Width,Height.
data.mouse.x - h/2, data.mouse.y - h/2, //dx/dy Destination Left,Top coordinates.
h, h //dw/dh The Width,Height to draw the image in the destination canvas.
);
}
function fisheye() {
var data = $(this).data(pluginName),
d = data.settings.size,
mx = data.mouse.x, my = data.mouse.y,
srcpixels = data.m_ctx.getImageData(mx - d/2, my - d/2, d, d);
fisheyeTransform.call(this, srcpixels.data, data.xpixels.data, d, d);
data.ctx.putImageData(data.xpixels, mx - d/2, my - d/2);
}
function fisheyeTransform(srcData, xData, w, h) {
/*
* Fish eye effect (barrel distortion)
* *** adapted from ***
* tejopa, 2012-04-29
* http://popscan.blogspot.co.ke/2012/04/fisheye-lens-equation-simple-fisheye.html
*/
var data = $(this).data(pluginName),
y, x, ny, nx, ny2, nx2, r, nr, theta, nxn, nyn, x2, y2, pos, srcpos;
for (var y=0; y<h; y++) { // for each row
var ny = ((2 * y) / h) - 1; // normalize y coordinate to -1 ... 1
ny2 = ny * ny; // pre calculate ny*ny
for (x=0; x<w; x++) { // for each column
pos = 4 * (y * w + x);
nx = ((2 * x) / w) - 1; // normalize x coordinate to -1 ... 1
nx2 = nx * nx; // pre calculate nx*nx
r = Math.sqrt(nx2 + ny2); // calculate distance from center (0,0)
if(r > 1) {
/* 1-to-1 pixel mapping outside the circle */
/* An improvement would be to make this area transparent. ?How? */
xData[pos+0] = srcData[pos+0];//red
xData[pos+1] = srcData[pos+1];//green
xData[pos+2] = srcData[pos+2];//blue
xData[pos+3] = srcData[pos+3];//alpha
}
else if(data.settings.border && data.settings.border !== 'none' && r > (1-3/w) && r < 1) { // circular border around fisheye
xData[pos+0] = data.settings.border[0];//red
xData[pos+1] = data.settings.border[1];//green
xData[pos+2] = data.settings.border[2];//blue
xData[pos+3] = data.settings.border[3];//alpha
}
else if (0<=r && r<=1) { // we are inside the circle, let's do a fisheye transform on this pixel
nr = Math.sqrt(1 - Math.pow(r,2));
nr = (r + (1 - nr)) / 2; // new distance is between 0 ... 1
if (nr<=1) { // discard radius greater than 1.0
theta = Math.atan2(ny, nx); // calculate the angle for polar coordinates
nxn = nr * Math.cos(theta); // calculate new x position with new distance in same angle
nyn = nr * Math.sin(theta); // calculate new y position with new distance in same angle
x2 = Math.floor(((nxn + 1) * w) / 2); // map from -1 ... 1 to image coordinates
y2 = Math.floor(((nyn + 1) * h) / 2); // map from -1 ... 1 to image coordinates
srcpos = Math.floor(4 * (y2 * w + x2));
if (pos >= 0 && srcpos >= 0 && (pos+3) < xData.length && (srcpos+3) < srcData.length) { // make sure that position stays within arrays
/* get new pixel (x2,y2) and put it to target array at (x,y) */
xData[pos+0] = srcData[srcpos+0];//red
xData[pos+1] = srcData[srcpos+1];//green
xData[pos+2] = srcData[srcpos+2];//blue
xData[pos+3] = srcData[srcpos+3];//alpha
}
}
}
}
}
}
// ********************************
// ***** Fin: Private methods *****
// ********************************
// *********************************
// ***** Start: Public Methods *****
// *********************************
var methods = {
'init': function(options) {
//"this" is a jquery object on which this plugin has been invoked.
return this.each(function(index) {
var can = this,
$this = $(this);
var data = $this.data(pluginName);
if (!data) { // If the plugin hasn't been initialized yet
data = {
target: $this,
showLens: false,
mouse: {x:0, y:0}
};
$this.data(pluginName, data);
var settings = {
imgSrc: '',
imgCrossOrigin: '',
drawImageCoords: [
0, 0, //sx,st Source image sub-rectangle Left,Top coordinates.
500, 500, //sw/sh Source image sub-rectangle Width,Height.
0, 0, //dx/dy Destination Left,Top coordinates.
500, 500 //(dw/dh) Destination image sub-rectangle Width,Height.
],
effect: 'fisheye',
scale: 2,
size: 100,
border: [0, 0, 0, 255], //[r,g,b,alpha] base-10
hideCursor: false
};
if(options) {
$.extend(true, settings, options);
}
data.settings = settings;
if(settings.hideCursor) {
data.originalCursor = $this.css('cursor');
$this.css('cursor', 'none');
}
$this.on('mouseenter.'+pluginName, function(e) {
data.showLens = true;
}).on('mousemove.'+pluginName, function(e) {
data.mouse.x = e.offsetX;
data.mouse.y = e.offsetY;
}).on('mouseleave.'+pluginName, function(e) {
data.showLens = false;
});
data.m_can = $("<canvas>").attr({
'width': can.width,
'height': can.height
})[0];
data.ctx = can.getContext("2d"); // lens effect
data.m_ctx = data.m_can.getContext('2d'); // background image
data.xpixels = data.ctx.getImageData(0, 0, settings.size, settings.size);
data.img = new Image();
data.img.onload = function() {
putBg.call(can);
animate.call(can);
};
data.img.crossOrigin = settings.imgCrossOrigin;
data.img.src = settings.imgSrc;
}
});
},
'destroy': function() {
return this.each(function(index) {
var $this = $(this),
data = $this.data(pluginName);
$this.off('mouseenter.'+pluginName)
.off('mousemove.'+pluginName)
.off('mouseleave.'+pluginName);
if(data && data.originalCursor) {
$this.css('cursor', data.originalCursor);
}
$this.data(pluginName, null);
});
}
};
// *******************************
// ***** Fin: Public Methods *****
// *******************************
// *****************************
// ***** Start: Supervisor *****
// *****************************
$.fn[pluginName] = function( method ) {
if ( methods[method] ) {
return methods[method].apply( this, Array.prototype.slice.call( arguments, 1 ));
} else if ( typeof method === 'object' || !method ) {
return methods.init.apply( this, arguments );
} else {
$.error( 'Method ' + method + ' does not exist in jQuery.' + pluginName );
}
};
// ***************************
// ***** Fin: Supervisor *****
// ***************************
})(jQuery);
And here's a Demo.
Edit
Here's an attempt at explaining the fisheye (barrel distortion) calculations ...
- Starting with a blank lens of
w x h
pixels.
- The code loops through all pixels (target pixels).
- For each target pixel, chooses a pixel (source pixel) from the background image.
- The source pixel is always selected from those on (or close to) the same radial ray as the target, but at a smaller radial distance (using a formula for barrel distortion) from the lens's center.
- This is mechanised by calculation of the polar coordinates
(nr, theta)
of the source pixel, then the application a standard math formula for converting polar back to rectangular coordinates nxn = nr * Math.cos(theta)
and nxn = nr * Math.sin(theta)
. Up to this point, all calculations have been made in normalised -1...0...1 space.
- The rest of the code in the block denormaises (rescales), and (the bit I had to heavily adapt) actually implements the source to target pixel mapping by indexing into the 1-dimensional source and target data.