A while ago I came across a post on Devirtuoso, on creating a 3D sphere using jQuery. There is fantastic demo on the post, but what really made it stand out was a lack of dependency on the canvas element, so it should be cross browser compatible. That is exceptional and when my colleagues found out about it, it was decided, this needs to power our new portfolio page.
“Okay”, I said. “That should be fine.” They do know how bad my maths is, don’t they?
The big idea. We are currently redeveloping the Ink Creations web site, the old site had been up for a few years and little had changed. We want the new site to be a reflection of the way we work/think with each page exploring a different aspect. Like a programmers playground, but more polished. One of these pages is our portfolio. We wanted to present our portfolio in an interesting new way while still remaining accessible. The 3D sphere gives us the interesting, but not so much the accessible. The sphere is comprised of plus signs that are generated in JavaScript, which are invisible to screen readers and search engines.
There are other issues with the sphere, in the context of our requirements:
- Each of the plus signs would need to be replaced with a unique portfolio image, but the rendering of the sphere allows for duplication of plus signs.
- The default behaviour of the sphere is to react to the position of the mouse, but to be constantly on the move. This makes tracking a particular plus sign very difficult. We would need our project images to be easily clicked.
- The plus signs are all the same solid colour, so there is no need to worry about the z-index of each element. With individual portfolio images, you don’t want the images at the back overlapping the ones at the front. No, no you don’t.
Lets deal with accessibility first. I still want to see a portfolio with JavaScript is switched off. The 3D sphere uses an unordered list which is good enough for me. So I start off by pre-rendering a list of each of our projects as an unordered list. Within the 3DEngine.js file I need to check for this and recreate the list if it doesn’t already exist.
//if there isn't a ul than it creates a list of +'s
if (container.children("ul").length === 0) {
var $ul = $("<ul></ul>").appendTo(this.container);
for (i = 0; i < this.pointsArray.length; i++) {
var project = g_projects[idx];
$ul.append("<li><img id=\"item" + i + "\" class=\"" + project.id + "\" src=\"/Image/Project/" + project.thumbnail.id + "?height=320&width=390\" /></li>");
idx = nextIndex(idx, g_projects);
}
}
I have created a global array of the projects called g_projects and have referenced it directly from 3DEngine.js. Now it is just a case of creating images instead of b elements. The pre-rendered ul list contains anchor tags as well, so the images are clickable. The fact that I haven’t done it in JavaScript is probably an oversight on my part.
We are now accessible, with JavaScript switched off we get an unordered list of portfolio images. With JavaScript switch on the images form a spinning ball.
While we’re in 3DEngine.js, we’ll deal with the z-index.
currItem.style.height = (37.5 * currItem.scale) + "px";
currItem.style.width = (50 * currItem.scale) + "px";
$(currItem).css({ opacity: (currItem.scale - .5), zIndex: Math.ceil((currItem.scale - .5) * 100) });
Using the scale that has already been provided I’m adjusting the photo’s size, opacity and z-index. This should hopefully add to the illusion of depth.
Sphere.js is where I encountered some difficulty. As previously mentioned mathematics isn’t my strongest attribute. The Sphere objects constructor takes three parameters; radius, sides and numOfItems. These parameters make up the configuration of the resulting sphere and as such can’t be applied randomly, it would seem. For instance, increasing the numOfItems may well increase the number of portfolio images, but will not necessarily increase the complexity of the sphere. Additional images occupy the space of existing images leading to undesirable results.
I’m positive the relationship between the three parameters can be worked out mathematically, I just haven’t worked out that relationship yet. Until I do, my solution has been to control which images are created based on their coordinates. This will remove the chance of images occupying the same space, but does require a little fiddling (with radius and sides) as our portfolio increases. This isn’t a huge concern as I believe our current portfolio count is already testing the CPU usage or your average browser to it’s limits, but any help in determining the fore said relationship would be very much appreciated. The code for Sphere.js is below:
var Sphere = function (radius, sides, numOfItems) {
var arr = new Array();
var totalPerSide = Math.ceil(numOfItems / sides);
var comparer = ["x", "y", "x"];
for (var j = 1; j < (sides + 1); ++j) {
for (var i = 0; i < totalPerSide; ++i) {
if (this.pointsArray.length >= numOfItems)
break;
var angle = i * Math.PI * 2 / totalPerSide;
var angleB = j * Math.PI * 2 / sides;
var x = Math.sin(angle) * Math.sin(angleB) * radius;
var y = Math.cos(angle) * Math.sin(angleB) * radius;
var z = Math.cos(angleB) * radius;
x = Math.round(x * 100) / 100;
y = Math.round(y * 100) / 100;
z = Math.round(z * 100) / 100;
var points = { x: x, y: y, z: z };
if (!arr.contains(points, comparer)) {
arr.push(points);
this.pointsArray.push(this.make3DPoint(x, y, z));
}
}
}
delete arr;
};
Sphere.prototype = new DisplayObject3D();
Array.prototype.unique = function () {
var r = new Array();
o: for (var i = 0, n = this.length; i < n; i++) {
for (var x = 0, y = r.length; x < y; x++) {
if (r[x] == this[i]) {
continue o;
}
}
r[r.length] = this[i];
}
return r;
};
Array.prototype.contains = function (obj, properties) {
for (var i = 0, n = this.length; i < n; i++)
if (_equals(this[i], obj, properties))
return true;
return false;
};
var _equals = function (a, b, properties) {
if (!properties)
return a == b;
for (var i = 0; i < properties.length; i++)
if (a[properties[i]] != b[properties[i]])
return false;
return true;
};
I believe this leaves us with the issue of actually selecting a project and clicking on the image for more information.
var g_portfolio = (function () {
$(document).ready(function () {
if (g_speed && g_speed.isSlow())
return;
var camera = new Camera3D();
camera.init(0, 0, 0, 300);
var container = $("#item");
container.css({
margin: "0 auto",
top: "46%",
position: "absolute",
left: "50%"
});
container.find("ul").css({
position: "static"
});
container.find("img").css({
position: "absolute",
zIndex: 100
});
container.find("div").remove();
var item = new Object3D(container);
var sphere = new Sphere(175, 13, _projectCount);
item.addChild(sphere);
var scene = new Scene3D();
scene.addToScene(item);
var mouseX = 600;
var mouseY = 0;
var offsetX = $("#item").offset().left;
var offsetY = $("#item").offset().top;
var speed = 15000;
var moving = null;
$("#controls").click(function (evt) {
evt.preventDefault();
if (moving)
stopTracking();
else startTracking();
});
$("#item a").click(function (evt) { evt.preventDefault(); });
$("#item img")
.hover(function () {
stopTracking();
var $this = $(this);
$this.data("state.3d", $.extend({}, { height: $this.height(), width: $this.width(), opacity: $this.css("opacity") }, $this.position()));
$this.animate({ height: "+=18px", width: "+=18px", opacity: 1, top: "-=9px", left: "-=9px" });
}, function () {
var $this = $(this);
var state = $this.data("state.3d");
$this.stop(false, true).css(state);
startTracking();
})
.click(function (evt) {
location.href = "/portfolio/carousel/" + $(this).attr("class");
});
var animateIt = function () {
if (!moving)
return;
if (mouseX != undefined) {
axisRotation.y += (mouseX) / speed
}
if (mouseY != undefined) {
axisRotation.x -= mouseY / speed;
}
scene.renderCamera(camera);
};
var mouseMove = function (evt) {
mouseX = evt.clientX - offsetX - (container.width() / 2);
mouseY = evt.clientY - offsetY - (container.height() / 2);
}
var startTracking = function () {
$(document).mousemove(mouseMove);
moving = setInterval(animateIt, 20);
};
var stopTracking = function () {
$(document).unbind("mousemove", mouseMove);
clearInterval(moving);
moving = null;
};
startTracking();
var $img = container.find("img");
var imgCount = $img.length;
var overflow = imgCount - sphere.pointsArray.length;
if (overflow > 0) {
var offset = (imgCount - overflow);
var margin = 20;
var withMargin = $img.width() + margin;
var left = -((withMargin * overflow) / 2) + (margin * 2);
for (var i = 0; i < overflow; ++i) {
$img.eq(offset + i).css({
left: left,
top: 0
});
left += withMargin;
}
}
});
})();
In the code above, I have refined the initialisation of the sphere to allow for the addition of a stop/start button. The sphere pauses when the mouse moves over an image, the said image then slightly increases in size to give emphasis. The last section of the above example deals with the remaining projects that didn’t fit within the sphere. It’s not ideal, but after running a few scenarios, I’ve found that I’m not able to guarantee a sphere that will encompass all projects, sometimes there is a remainder. These projects are lined up in the centre of the sphere.
And that is the current incarnation of our portfolio page. The full site is still under development and I’m sure the portfolio section still has some cosmetic changes to come. My prediction is some sort of featured projects sphere with a link to a full list. Until that happens, all the code is available uncompressed on the portfolio, when finalised and we’ve optimised the performance reasons, I’ll endeavour post the source to a follow up post. Our portfolio page can be found http://inkdigitalagency.com/portfolio.