In my last post I wrote about some requirement and limitations that we should take into account when implementing a good image gallery. Now it’s time to dive into the details of developing such gallery in Meteor. The code provided here was written to solve a real need in my application, it wasn’t intended for educational/library purposes so it is less than perfect. Please tell me if you think it can be done better.

I my code I assume that I all image metadata can be loaded from the database before rendering the gallery. It is good enough in my case. If it is a problem with your application then you could simply add pagination logic to load in smaller chunks.

Pre-requirements:

Track the gallery scroll position reactively – as you will see in the following snippets, we need to respond to changes in gallery scroll position. Storing this position in a reactive variable will trigger these responses.

# Set mouse wheel to scroll horizontally (the gallery is horizontal) $("body").mousewheel((event, delta) -> $("#galleryScroll")[0].scrollLeft -= delta * config.gallery.sensitivityScroll event.preventDefault(); )

Part #1: Get the data

Scroll position is the most important parameter in this problem. It defines what data should be displayed and which shouldn’t. We also need to know in advance the position of every image to decide if it’s visible or not. In my gallery (design consideration) the position of one image depends on the position of all the images that precede it, so fetching is required. The following reactive autorun calculates the positions of all images in the gallery. It is a reactive context which is invalidated as the data (images) or layout (number of rows, row height, etc.) changes.

# Set location Session.set("photosSorted", []) #################### Position photos #################### Meteor.autorun(-> nRows = Session.get("nRows") # Spacing between the thumbnails spaceThumbY = config.gallery.spaceThumbY spaceThumbX = config.gallery.spaceThumbX # Current horizontal in every row xCurrentMod = (spaceThumbX for a in [1..nRows]) yCurrentMod = (spaceThumbY + (2*spaceThumbY + Session.get("heightThumb"))*idx for idx in [0..nRows-1]) idx = 0 photosTmp = [] photosInView().forEach (photo) -> # Gallery layout specific logic rowCurrent = idx % mod xCurrent = xCurrentMod[rowCurrent] widthPhoto = widthThumb(photo) leftPhoto = xCurrent + spaceThumbX rightPhoto = leftPhoto + widthPhoto + spaceThumbX # Absolute position of thumbnails in the gallery Session.set("left" + photo._id, leftPhoto) Session.set("top" + photo._id, yCurrentMod[rowCurrent]) photosTmp.push({"left": leftPhoto, "right": rightPhoto, "_id": photo._id}) xCurrentMod[rowCurrent] = rightPhoto idx += 1 # Array of all thumbnails sorted from left to right (the scroll direction) Session.set("photosSorted", _.sortBy(photosTmp, (x) -> x.left)) # The rightmost part of the gallery Session.set("scrollLeftEnd", Math.max.apply(this, xCurrentMod)) )

So what did we do here? We calculated two things. One is the position of every image. It would help us placing the image in the gallery. But more important, it would be the key to loading/pre-loading/unloading images.

Part #2: Load the photos

After we know the location of every image, now is the time to load and display the appropriate images based on the scroll position.

#################### Load photos #################### scrollLeft = 0 photosVisible = [] photosToLoad = [] Meteor.autorun( -> # Wehenver the the gallery scroll position changes the gallery # contentes need to be recalculated. scrollLeftNew = Session.get("galleryScrollLeft") widthScroll = Session.get("widthGallery") # Control scroll sensitivity - For fluent UI we don't want to # respond to every tiny mouse scroll, we are safe with our # pre-loaded margins diffScroll = Math.abs(scrollLeftNew - scrollLeft) underMinScrol = diffScroll < widthScroll/config.gallery.minScrollFactor if underMinScrol return scrollLeft = scrollLeftNew # This is the margin around the viewport that we use to pre-load images that # are not visible yet. `leftScroll` and `rightScroll` are the horizontal # limits of the gallery region we are going to populate with images margin = widthScroll*config.gallery.preloadFactor leftScroll = scrollLeft - margin rightScroll = leftScroll + widthScroll + 2*margin # We use an efficient binary search here to find the range of visible # images. We use the reactive sorted array that we calculated earlier photosSorted = Session.get("photosSorted") posLeft = _.sortedIndex(photosSorted, "left": leftScroll, (x) -> x.left) posRight = _.sortedIndex(photosSorted, "left": rightScroll, (x) -> x.left) # Ordering the array so the visible are loaded first # This is a little trick that re-arranges the array # of images to be loaded from: # left-of-viewport -> in-viewport -> right-of-viewport # to: # in-viewport -> left-of-viewport -> right-of-viewport # It would affect our pre-loading and cause the visible # images to be loaded first photosVisibleNew = [] photosVisibleMargin = [] for photo in photosSorted[posLeft..posRight] inLeft = (photo.left - scrollLeft > 0) and (photo.left - scrollLeft < widthScroll) inRight = (photo.right - scrollLeft > 0) and (photo.right - scrollLeft < widthScroll) if inRight or inLeft photosVisibleNew.push(photo._id) else photosVisibleMargin.push(photo._id) photosVisibleNew.push(photosVisibleMargin...) # Concat # These are the images that were visible and have to be removed updatesFalse = _.difference(photosVisible, photosVisibleNew); photosVisible = photosVisibleNew # These are the images that have to be pre-loaded in order to control # the loading order and not leave it to the browser. photosToLoad = photosVisible[..] # Copy # This triggers template rendering for id in photosVisible Session.set("visible" + id, true) # This triggers template removal for id in updatesFalse Session.set("visible" + id, false) )

The images loading happens in the background. Notice that when the scroll position changes there might be images that were in the loading queue but weren’t loaded yet. Here we simply remove them from the queue, while if we let the browser load it using img tag, we’d have to wait until they are fully loaded before currently visible images could be loaded. This happens when one scroll fast to the end of the gallery. Image pre-loading:

# The number of images being pre-loaded at any given moment doing = 0 loading = [] Meteor.setInterval( -> # Function that marks an image as loaded load = (id) -> return -> doing -= 1; Session.set("loaded" + id, true); # Try to load images until there are no more or we reached our limit of # concurrent loads. All images that have not been loaded will be loaded # during the next call to this interval call while doing < config.gallery.nPhotosPreload and photosToLoad.length > 0 # Take photo out of the load queue id = photosToLoad.shift() # Skip if already loaded if not Session.get("loaded" + id) doing += 1 # Trigger loading img = new Image img.onload = load(id) photo = Photos.findOne("_id": id) # This could be eliminated img.src = photo.src loading.push(img) 100 )

We limit the number of concurrent loads as we can’t cancel a load that was already initiated. Having too many concurrent loads hurts responsiveness. Imagine that you scrolled to a position in the gallery and then scrolled again before the images were loaded. To see the images in the new position you would have to wait until all images from the last position are loaded.

Part #3: The gallery

Now that we have all the data ready it is time to render it into a nice gallery:

<template name="groupThumbs"> {{#each photos}} <!-- Show the image box even if not visible (aesthetic reasons only) --> <div class="group-thumb" style="height:{{heightThumb}}px;width:{{widthThumb}}px;left:{{left}}px;top:{{top}}px"> <div class="group-inner"> {{#isolate}} <!-- No need to render the entire gallery because one image changed --> {{#if isVisible}} <img src="{{photoThumb}}" class="{{classLoaded}}"> {{/if}} {{/isolate}} <div> </div> {{/each}} </template>

Template.groupThumbs.helpers heightThumb: -> getHeightThumb() widthThumb: -> ratio = getHeightThumb() / @photo.height return Math.round(@photo.width * ratio) left: -> Session.get("left" + @_id) top: -> Session.get("top" + @_id) isVisible: -> Session.get("visible" + @_id) and Session.get("loaded" + @_id)

Done!

If you got this far, you should be able to write your own gallery. One thing I must add about this code. It’s very sensitive, almost every change made will affect performance/user experience.

If you think you can make this code better, then please let me know.

Good luck!