Zoooom

Monday, Aug 9, 2021
#web #photography

Photography is a big hobby of mine – many of my pictures are here on my site and can be considered CC BY-NC 4.0. On the web version of my site, I thought long and hard about how to display my pictures (on gemini that’s all up to the client so I needn’t worry about it 😋). I decided on a grid of images with one to three columns (depending on screen width) and varied height for showing different aspect ratios nicely.

The first step was figuring out how to represent my image collection with hugo. Originally, I was creating a blank markdown file for every image and using the frontmatter for storing a filename and “height” value. This kinda worked, but was a pain to create new images and felt like a weird hack. Eventually, I learned about creating and using json data files with hugo. I actually had a really hard time figuring this out. Hugo’s documentation is very sparse, and the free hugo templates seem to use massive front-end js libraries just to render some basic text. Very disappointing. I wound up buying this book about Hugo to get a much better grasp of how everything worked. I then created the following layout:

Hugo Layout

<!DOCTYPE html>
<html>
{{- partial "head-pics.html" . -}}
<body>
  <div class="container">
    {{- partial "nav.html" . -}}
    <main class="index">
      <section class="list">
        <h1>{{ .Title }}
          <div class="rss"></div>
        </h1>
        <div class="grid">
          {{ range .Site.Data.pics.pictures }}
          <span class="grid-item" style="grid-row-end: span {{ .height }};">
            <picture class="zoom" data-full-size="/pics/{{ .name }}.full.webp">
              <source srcset="/pics/{{ .name }}.thumb.webp" type="image/webp">
              <source srcset="/pics/{{ .name }}.thumb.jpg" type="image/jpg">
              <img src="/pics/{{ .name }}.full.jpg" width="100%" height="100%">
            </picture>
          </span>
          {{ end }}
        </div>
      </section>
    </main>
  </div>
</body>
</html>

JSON Data Sample

{
	"pictures": [
		{
			"name": "DSCF8909",
			"date": "2021:07:11T14:27:33",
			"height": "2"
		},
		{
			"name": "DSCF8823",
			"date": "2021:07:11T13:16:27",
			"height": "3"
		},
		{
			"name": "DSCF8822",
			"date": "2021:07:11T13:15:46",
			"height": "4"
		},
  ]
}

Exported HTML

<div class="grid">
  <span class="grid-item" style="grid-row-end: span 2;">
    <picture data-full-size="/pics/DSCF8909.full.webp">
      <source srcset="/pics/DSCF8909.thumb.webp" type="image/webp">
      <source srcset="/pics/DSCF8909.thumb.jpg" type="image/jpg">
      <img src="/pics/DSCF8909.full.jpg" width="100%" height="100%">
    </picture>
  </span>
<div>

The filename has an extension tacked on the end for each of the different stored versions (webp and jpeg thumbnails and full images). The grid-row-end: span 2; is set using the height value, larger values are “taller”. Then, with some help from my friend Matthew, I was able to pad out some scss to place the images in a grid.

Image SCSS

img {
  object-fit: cover;
  object-position: center;
  cursor: pointer;
}

.grid {
  display: grid;
  grid-template-columns: 1fr 1fr 1fr;
  grid-auto-rows: 10vw;
  position: absolute;
  padding: 0 2%;
  left: 0;
  right: 0;
  @media only screen and (max-width: 850px) {
    grid-auto-rows: 15vw;
    grid-template-columns: 1fr 1fr;
  }
  @media only screen and (max-width: 500px) {
    grid-auto-rows: 30vw;
    grid-template-columns: 1fr;
  }
}

.grid-item {
  padding: 2%;
  grid-row-end: span 3;
  img {
    width: 100%;
    height: 100%;
    display: block;
    border-radius: 10px;
  }
}

If you frequently battle with css you may notice my images are effectively cropped by this css. That’s intentional – I wanted my grid of images to look smooth and orderly, but now as a consequence some of my images have flawed framing. I decided to have my images zoom in when clicked on and “expand” into the proper aspect ratio. My friend Matthew was able to save the day once again by writing me a basic script to zoom an element.

Zoooom

// Outwardly accessable zoom parameters
let zoomParams = {
  padding: [120, 120],
  zIndex: 2,
  backgroundColor: "#FFF"
};
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
if (prefersDark) {
  zoomParams.backgroundColor = "#000";
} else {
  zoomParams.backgroundColor = "#FFF";
}

// Anonymous function so this script won't conflict with any others
(() => {

  // Cache dom
  const zoomers = document.getElementsByClassName("zoom");
  const body = document.getElementsByTagName("body")[0];

  // Add the background html to manipulate later
  const background = document.createElement("div");
  background.style.position = "fixed";
  background.style.top = "0";
  background.style.right = "0";
  background.style.bottom = "0";
  background.style.left = "0";
  background.style.backgroundColor = zoomParams.backgroundColor;
  background.style.zIndex = zoomParams.zIndex.toString();
  background.style.display = "none";
  background.style.opacity = "0";
  background.style.transition = "0.3s ease";
  body.appendChild(background);

  // Main zooming function
  const zoooom = (event) => {
    // Declare variables
    const element = event.currentTarget;
    const clone = element.cloneNode(true);

    const location = element.getBoundingClientRect();
    const width = location.width;
    const height = location.height;

    const viewportWidth = document.documentElement.clientWidth;
    const viewportHeight = document.documentElement.clientHeight;
    const scrollDistance = document.documentElement.scrollTop;

    // Transform values for zoomed image
    const scale = Math.min(
      (viewportWidth - zoomParams.padding[0]) / width,
      (viewportHeight - zoomParams.padding[1]) / height
    );
    const translateX = (viewportWidth / 2) - location.left - (width / 2);
    const translateY = (viewportHeight / 2) - location.top - (height / 2);

    // Declare unzoom function for relevant element
    const unzoom = () => {
      // Undo the transform to return cloned element to the original position
      clone.style.transform = "unset";
      background.style.opacity = "0";
      // Wait for the cloned element to return
      setTimeout(() => {
        // Remove the cloned element
        clone.remove();
        background.style.display = "none";
        // Unbind the un-needed events
        document.removeEventListener("scroll", unzoom)
        clone.removeEventListener("click", unzoom)
        background.removeEventListener("click", unzoom)
        window.removeEventListener("resize", unzoom)
      }, 300);
    }

    // Put the cloned element in the document
    body.append(clone);
    // Apply styles to the cloned element
    clone.style.transition = "0.3s ease";
    clone.style.position = "absolute";
    clone.style.top = (scrollDistance + location.top).toString() + "px";
    clone.style.left = location.left.toString() + "px";
    clone.style.width = width.toString() + "px";
    clone.style.height = height.toString() + "px";
    clone.style.margin = "0";
    clone.style.zIndex = (zoomParams.zIndex + 1).toString();
    // Show the background
    background.style.display = "block";
    // wait a moment to set the transform to ensure the transitin takes effect
    setTimeout(() => {
      clone.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
      background.style.opacity = "1";
    }, 50);

    // Bind scroll, click, and window resize to unzoom
    document.addEventListener("scroll", unzoom);
    clone.addEventListener("click", unzoom);
    background.addEventListener("click", unzoom);
    window.addEventListener("resize", unzoom);
  }

  // Bind events to all zoomable elements
  for (zoomer of zoomers) {
    zoomer.addEventListener("click", zoooom);
  }

})();

This script will zoom in elements with class="zoom" and unzoom if you scroll, click, or resize the window. The element is copied and a “background” div is created, then the background is faded in while the copied element is moved and resized with css translate() which is very fast and effecient. Translate has one big drawback due to it’s speed, you can’t animate the source aspect ratio changing, you can stretch the image, but not “expand” it into the proper ratio. I use this script for images in my notes and on my code page since they don’t need the aspect ratio changed.

For my pics page I modified the script to “expand” the image while zooming it in. It looks pretty neat, it’s slower than translate(), but now my images show the correct ratio. Additionally this new script swaps out the lower quality thumbnail image with a higher res version once it’s zoomed in. Clicking on my photo of a boat shows the effect really well.

Zoooom N' Scale

// Outwardly accessable zoom parameters
let zoomParams = {
  padding: [40, 40],
  zIndex: 2,
  backgroundColor: "#FFF"
};
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
if (prefersDark) {
  zoomParams.backgroundColor = "#000";
} else {
  zoomParams.backgroundColor = "#FFF";
}

// Anonymous function so this script won't conflict with any others
(() => {

  // Cache dom
  const zoomers = document.getElementsByClassName("zoom");
  const body = document.getElementsByTagName("body")[0];

  // Add the background html to manipulate later
  const background = document.createElement("div");
  background.style.position = "fixed";
  background.style.top = "0";
  background.style.right = "0";
  background.style.bottom = "0";
  background.style.left = "0";
  background.style.backgroundColor = zoomParams.backgroundColor;
  background.style.zIndex = zoomParams.zIndex.toString();
  background.style.display = "none";
  background.style.opacity = "0";
  background.style.transition = "0.3s ease";
  body.appendChild(background);

  // Main zooming function
  const zoooom = (event) => {
    // Declare variables
    const element = event.currentTarget;
    const pic = element.getElementsByTagName('img')[0];
    const clone = element.cloneNode(true);

    // Cropped display size
    const location = element.getBoundingClientRect();
    const disWidth = location.width;
    const disHeight = location.height;

    // Original image size
    const natWidth = pic.naturalWidth;
    const natHeight = pic.naturalHeight;

    const viewportWidth = Math.max(
      document.documentElement.clientWidth || 0,
      window.innerWidth || 0
    );
    const viewportHeight = Math.max(
      document.documentElement.clientHeight || 0,
      window.innerHeight || 0
    );
    const scrollDistance = document.documentElement.scrollTop;

    // Transform values for zoomed image
    const scale = Math.min(
      (viewportWidth - zoomParams.padding[0]) / natWidth,
      (viewportHeight - zoomParams.padding[1]) / natHeight
    );
    const scaleWidth = scale * natWidth;
    const scaleHeight = scale * natHeight;
    const scaleTop = (viewportHeight / 2) - (scaleHeight / 2) + scrollDistance;
    const scaleLeft = (viewportWidth / 2) - (scaleWidth / 2);

    // Declare unzoom function for relevant element
    const unzoom = () => {
      // return cloned element to the original position
      clone.style.transition = "0.3s";
      clone.style.top = (scrollDistance + location.top).toString() + "px";
      clone.style.left = location.left.toString() + "px";
      clone.style.width = disWidth.toString() + "px";
      clone.style.height = disHeight.toString() + "px";
      background.style.opacity = "0";
      // Wait for the cloned element to return
      setTimeout(() => {
        // Remove the cloned element
        clone.remove();
        background.style.display = "none";
        // Unbind the un-needed events
        document.removeEventListener("scroll", unzoom)
        clone.removeEventListener("click", unzoom)
        background.removeEventListener("click", unzoom)
        window.removeEventListener("resize", unzoom)
      }, 300);
    }

    // Put the cloned element in the document
    body.append(clone);
    // Apply styles to the cloned element
    clone.style.position = "absolute";
    clone.style.top = (scrollDistance + location.top).toString() + "px";
    clone.style.left = location.left.toString() + "px";
    clone.style.width = disWidth.toString() + "px";
    clone.style.height = disHeight.toString() + "px";
    clone.style.margin = "0";
    clone.style.zIndex = (zoomParams.zIndex + 1).toString();
    // Show the background
    background.style.display = "block";
    // wait a moment to set the transform to ensure the transition takes effect
    setTimeout(() => {
      clone.style.transition = "1s";
      clone.style.top = scaleTop.toString() + "px";
      clone.style.left = scaleLeft.toString() + "px";
      clone.style.width = scaleWidth.toString() + "px";
      clone.style.height = scaleHeight.toString() + "px";
      background.style.opacity = "1";
      // If there is data for a full size version
      if (clone.dataset.fullSize) {
        // Create a new image source and prepend it to the picture element
        const fullSizeSource = document.createElement("source");
        fullSizeSource.srcset = clone.dataset.fullSize;
        clone.prepend(fullSizeSource);
      }
    }, 50);

    // Bind scroll, click, and window resize to unzoom
    document.addEventListener("scroll", unzoom);
    clone.addEventListener("click", unzoom);
    background.addEventListener("click", unzoom);
    window.addEventListener("resize", unzoom);
  }

  // Bind events to all zoomable elements
  for (zoomer of zoomers) {
    zoomer.addEventListener("click", zoooom);
  }
})();