I’ve written new landing pages for jasondonenfeld.com and zx2c4.com, taking my first plunge into the wonderfully easy world of jQuery. I’m also now separating the content of the two domains — jasondonenfeld.com for personal things and zx2c4.com for geek things. The only thing missing in this area is moving this blog over to zx2c4.com. Update 12/5/10: Moved to blog.zx2c4.com.

After sticking exclusively with straight-up javascript for years and years (it was actually my first programming language), and struggling with the DOM and all the various browser quirks, I finally caved in and tried out jQuery. It’s awfully nice, though it seems to encourage a few design patterns that might not always be optimal.

jasondonenfeld.com pulls images via AJAX from my zenphoto installation on photos.jasondonenfeld.com. It also has a small list of various recordings I had laying around my hard drive, playable using HTML5 or, as a fall-back, flash, implemented using the jPlayer plugin for jQuery, heavily modified. The background smoothly transitions through a series of images.

To do the fading, I wrote my first jQuery plugin, fadeshow:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
(function($) {
        $.fn.fadeShow = function(options, images, changeCallback)
        {
                var self = this;
 
                self.css("overflow", "hidden");
 
                if (options.shuffle) {
                        var index = images.length;
                        while (index) {
                                var random = Math.floor(Math.random() * index);
                                var old = images[--index];
                                images[index] = images[random];
                                images[random] = old;
                        }
                }
                if (!options.fadeTime || options.fadeTime <= 0)
                        options.fadeTime = 1000;
                if (!options.imageTime || options.imageTime <= 0)
                        options.imageTime = 4000;
 
                self.currentImage = 0;
 
                var sizeToRatio = function(image) {
                        if (!image.ratio)
                                return;
 
                        var height, width;
                        if (!image.hasCompleted) {
                                if (self.width() / self.height() > image.ratio) {
                                        image.width = self.width();
                                        image.height = self.width() / image.ratio;
                                } else {
                                        image.height = self.height();
                                        image.width = self.height() * image.ratio;
                                }
                                height = image.height;
                                width = image.width;
                        } else {
                                height = $(image).height();
                                width = $(image).width();
                        }
                        if (self.width() / self.height() > image.ratio)
                                $(image).css("width", "100%").css("height", "auto");
                        else
                                $(image).css("height", "100%").css("width", "auto");
                        $(image).css("top", Math.min(0, (self.height() - height) / 2).toString() + "px");
                        $(image).css("left", Math.min(0, (self.width() - width) / 2).toString() + "px");
                }
 
                var establishNaturalSize = function(image) {
                        if (this.naturalHeight && this.naturalWidth)
                                return;
                        var i = new Image();
                        i.onload = function() {
                                image.naturalHeight = this.height;
                                image.naturalWidth = this.width;
                        }
                        i.src = image.src;
                }
                var imageLoadSetRatio = function() {
                        establishNaturalSize(this);
                        this.ratio = this.width / this.height;
                        this.hasCompleted = true;
                        sizeToRatio(this);
                };
                var imageLoad = function() {
                        establishNaturalSize(this);
                        this.hasCompleted = true;
                };
                var functionWithTrigger = function(theFunction, needsTrigger, triggerImage) {
                        if (!needsTrigger || !changeCallback)
                                return theFunction;
                        return function() {
                                changeCallback(triggerImage);
                                theFunction.apply(triggerImage);
                        };
                };
 
                for (var i = 0; i < images.length; ++i) {
                        if (!images[i].src)
                                continue;
                        var image = document.createElement("img");
                        image.hasCompleted = false;
                        $(image).css("margin", "0px").css("position", "absolute");
                        if (i > 0)
                                $(image).hide();
                        if (images[i].width && images[i].height) {
                                image.ratio = images[i].width / images[i].height;
                                image.onload = functionWithTrigger(imageLoad, i == 0, image);
                        } else {
                                image.ratio = 0;
                                image.onload = functionWithTrigger(imageLoadSetRatio, i == 0, image);
                        }
                        image.src = images[i].src;
                        if (image.ratio)
                                sizeToRatio(image);
                        self.append(image);
                }
 
                $(window).resize(function() {
                        self.children("img").each(function(index, image) {
                                sizeToRatio(image);
                        });
                });
 
                var nextImage = function() {
                        var children = self.children("img");
                        var current = children[self.currentImage];
                        var next;
                        do {
                                if (++self.currentImage >= children.length)
                                        self.currentImage = 0;
                                next = children[self.currentImage];
                                if (next == current)
                                        return;
                        } while (!next.hasCompleted);
                        if (self.currentImage) {
                                $(next).fadeIn(options.fadeTime, function() {
                                        $(current).hide();
                                        if (changeCallback)
                                                changeCallback(next);
                                });
                        } else {
                                $(next).show();
                                $(current).fadeOut(options.fadeTime, function() {
                                        if (changeCallback)
                                                changeCallback(next);
                                });
                        }
                }
 
                window.setInterval(nextImage, options.imageTime);
 
                return self;
        };
})(jQuery);

It preloads all the images, and it always resizes to 100% in either height or width, depending on which dimension is optimal, and then crops equally on both sides for the non-optimal dimension. To use it, I quite simply enter this:

$("#background").fadeShow({ shuffle: true, imageTime: 10000, fadeTime: 2000 }, images);

–where images is an array of objects, each of which has at least an src property and optionally width & height properties.

One big issue, however, was that not all images had the same average background tone, so the white text was sometimes not visible enough in the black boxes’ opacity level. I didn’t want to increase the opacity all the time, though, because for some backgrounds it looks nice to have a low opacity.

The solution was to draw the image inside of an HTML5 canvas element, not rendered to the document but only in memory in javascript, and then determine the average V value of the background image in HSV colorspace (which is simply the maximum RGB value), by averaging the pixel data accessible from the canvas object, and only those pixels which are directly underneath the primary text-box in the upper left corner. (The HSV idea actually came from reading the QPalette source code, which I discovered when investigating how it determined button text color based on button background color.) Once I had the average V value for that area, I reasoned that I should take V/255, to get a color percentage, and then take one-third of that, since it seems like one third gray is about the maximum viewable background color (and I remember hearing something about this from my 6th grade art teacher). Then once I had that percent, I made sure it wasn’t lower than 10%, since I still want some visible bubble. The resulting algorithm is as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
$("#background").fadeShow({ shuffle: true, imageTime: 10000, fadeTime: 2000 }, images, function(image) {
	var canvas = document.createElement("canvas");
	if (!canvas.getContext) {
		delete canvas;
		return;
	}
	canvas.width = $(image).parent().width();
	canvas.height = $(image).parent().height();
 
	var context = canvas.getContext("2d");
	var top = -$(image).offset().top * image.naturalWidth / image.width;
	var left = -$(image).offset().left * image.naturalHeight / image.height;
	var width = $(image).parent().width() * image.naturalWidth / image.width;
	var height = $(image).parent().height() * image.naturalHeight / image.height;
	if (!width || !height) {
		delete canavs;
		return;
	}
	context.drawImage(image, left, top, width, height, 0, 0, canvas.width, canvas.height);
 
	var imageData = context.getImageData(0, 0, canvas.width, canvas.height);
 
	var name = $("#name");
	var maxSum = 0;
	var maxes = 0;
	for (var x = name.offset().left; x < (name.offset().left + name.width()); ++x) {
		for (var y = name.offset().top; x < (name.offset().top + name.height()); ++x) {
			var index = (y * (imageData.width * 4)) + (x * 4);
			maxSum += Math.max(imageData.data[index], imageData.data[index + 1], imageData.data[index + 2]);
			++maxes;
		}
	}
	$(".transparentbox").fadeTo(800, Math.max(.1, ((maxSum / maxes) / 255) / 3));
	delete canvas;
});

I have it nicely fade between opacities. If you watch jasondonenfeld.com closely enough, you’ll notice it, though it’s quite subtle.

The other layout issue is that I don’t really like document-level scrollbars very much. Instead, I opted to make the page never scroll, but just to hide the auxiliary boxes when the document becomes too small. But in order to inform the user that there is data to be shown, I show one or two arrows, depending on the dimensions. The code to do so is trivial, and the end result looks like this:

Over on the zx2c4.com end, several cool things are going on as well. I pull the latest blog posts using the JSON wordpress plugin using JSONP. Dates are nicely made fuzzy using the timeago plugin for jQuery. I also display a list of projects sorted by last-updated from my git repository. Then, to add the final touches, I show a super fast moving stream of random source code from a random project in my git repository, streaming continuously and randomly.

The background is not photoshopped at all. I took it with a cheap point-and-shoot digital camera when I was fourteen while sitting in the back of a fogged up car during winter at a gas station and shaking around the camera. I’m not sure how much I like the whole green matrix hacker aesthetic of the site, but maybe that’s what I ought to do.

On the server side, to get the source code from the git repositories (which are managed by gitolite, as mentioned in several other posts), I employ this very simple shell script:

1
2
3
4
5
6
7
8
9
10
11
#!/bin/sh
echo "Content-Type: text/plain"
echo
 
for i in {1..10}; do
project="/home/gitcode/repositories/$(shuf -n 1 /home/gitcode/projects.list)"
hash=$((git --git-dir="$project" ls-tree -r HEAD | egrep -i '.*\.(c|h|cpp|hpp|cc|hh|js|py|rb|sh|cgi|bash|txt|php|htm|html|shtml|java|pro|pri|nsi|bat|vbs|asp|cs|hs|sln|xml|xhtml|csproj|rst)$' | shuf -n 1 | cut -f 1 | cut -d ' ' -f 3) 2> /dev/null)
if [ "$hash" != "" ]; then
        git --git-dir="$project" cat-file -p $hash
fi
done

The shuf command turned out to be very handy. And to get the project list and their description and sort it by the latest commit, I use this custom script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#!/bin/sh
echo "Content-Type: application/json"
echo
 
notfirst=0;
echo "["
(cat /home/gitcode/projects.list | while read line; do
        (stat --printf "%Y\n" /home/gitcode/repositories/$line/refs/heads/* | sort -nr | head -n 1 | tr -d '\n') 2> /dev/null
        echo -e "\t$line"
done) | sort -nr | cut -f 2 |
while read line; do
        if [[ $notfirst == 1 ]]; then
                echo ","
        fi
        description=$(cat "/home/gitcode/repositories/$line/description")
        echo "{ \"name\": \"${line%.git}\", \"description\": \"$description\" }"
        notfirst=1
done
echo "]"

This returns a JSON array. To make it scroll in the javascript, I simply make a div with overflow:hidden and then modify the scroll position, and request more code when it’s near the bottom.

Last but certainly not least, I’ve gone through great pains to compress and minify all of my css and javascript in the most efficient way possible. After some testing with compression ratios, I picked Google’s Closure Compiler for javascript and Yahoo’s YUI Compressor for CSS. In order to keep track of which files have been compressed and incorporated into the single css file and single js file file for each page, I made a very generic and reusable make file that scans js/*.js and css/*.css, excluding already minified files, and incrementally makes js/*.min.js and css/*.min.css, and eventually creates js/scripts.min.js and css/styles.min.css as composites. I prefix each js and css file with a three digit code to preserve ordering, much like how udev manages rule priorities. Here’s my makefile:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
JS_DIR = js
CSS_DIR = css
 
JS_MIN = $(JS_DIR)/scripts.min.js
CSS_MIN = $(CSS_DIR)/styles.min.css
 
JS_MIN_FILES := $(patsubst %.js, %.min.js, $(filter-out %.min.js, $(wildcard $(JS_DIR)/*.js)))
CSS_MIN_FILES := $(patsubst %.css, %.min.css, $(filter-out %.min.css, $(wildcard $(CSS_DIR)/*.css)))
 
JS_COMPILER = google-compiler --warning_level QUIET
CSS_COMPILER = yuicompressor --type css
 
.PHONY: all clean
 
all: $(JS_MIN) $(CSS_MIN)
 
%.min.js: %.js
	@echo "Compiling javascript" $<
	@$(JS_COMPILER) --js $< --js_output_file $@
 
%.min.css: %.css
	@echo "Compiling stylesheet" $<
	@$(CSS_COMPILER) -o $@ $<
 
$(JS_MIN): $(JS_MIN_FILES)
	@echo "Assembling compiled javascripts"
	@cat $^ > $@
 
$(CSS_MIN): $(CSS_MIN_FILES)
	@echo "Assembling compiled stylesheets"
	@cat $^ > $@
 
clean:
	@rm -fv $(JS_MIN) $(JS_MIN_FILES) $(CSS_MIN) $(CSS_MIN_FILES)

As always, suggestions and comments for any of the above are very welcome.

Head on over to ZX2C4.COM and JasonDonenfeld.com.

October 16, 2010 · [Print]

5 Comments to “New Landing Pages”

  1. Dion Moult says:

    Nicely done webpages you got there! Unfortunately it feels to much of a technical sample than a real website for me to enjoy it. With a rather slow connection here the images are slow to load and appear, and scrolling works only when the mouse if over the text (Opera, latest unstable version)

    • Jason says:

      Yea… the image loading thing is problematic. Maybe I need to decrease the resolution of the images…

      I’m unable to reproduce the scrolling issue over here — using the latest linux opera 10.70. What version are you using?

  2. Dion Moult says:

    Opera 10.70 build 9050.

  3. Martin says:

    Nice things :) Would you mind please to reveal the AJAX magic behind the recent photos and random photos taken form PhotoFloat. I would like to try somehow to put such sidebox as a wordpress widget for the sidebar.

    Thanks a lot!

Leave a Reply