How to Create Scrollable Image Gallery for CT/MRIs

Does anyone know how to create a scrollable stacked image gallery for CT/MRIs similar to this?:

Tried inspecting the website but could not figure it out. I did find a way to make an image gallery but it’s not exactly “scrollable” or “stacked” although it could work if there’s not a more elegant solution: How To Create a Slideshow

Goal would be to create cards with one deidentified image slice on the front and a scrollable image gallery on the back for reference.

Thanks!

Fun idea!

Here’s a sample notetype: Gofile - Your all-in-one storage solution

Usage:

  • Paste your image sequence into the field “Sequence”.
  • Copy the frame you want to appear on the front into the field “Frame”.

The image sequence will be scrollable on the backside.


This is the script:

(() => {
    let activeImg;
    qImg = document.querySelector("#img > img");
    const images = document.querySelectorAll("#images img");
    for (const img of images) {
        const isActive = img.src === qImg.src;
        img.classList.toggle("active", isActive);
        if (isActive) {
            activeImg = img;
        }
    }

    document.getElementById("qa").addEventListener("wheel", (e) => {
        const nextImg = activeImg[e.deltaY > 0 ? "nextElementSibling" : "previousElementSibling"];
        if (!nextImg) {
            return;
        }
        activeImg.classList.remove("active");
        nextImg.classList.add("active");
        activeImg = nextImg;
    });
})();

The idea is to include all images on the backside, but hide them by default. The one that was displayed on the front gets the class active, which unhides the image. Using Element: wheel event - Web APIs | MDN we can detect scroll actions via touchpad or mousewheel and update the active element accordingly.

If you want to invert the scroll direction, look for e.deltaY > 0 and change it to e.deltaY < 0.

The website uses a single <img> element and updates its src attribute on scroll. I don’t know if that makes a difference performance-wise. All images need to be loaded anyway, but the rendering process might be different.

2 Likes

One other use case I can think of is to put a diagnosis into the answer field and put the script to the front side too. You’d just need a good source for pathological scans.

Thank you so much! Will try this!

This is such a gamechanger! Thank you for helping your non-radiologist medical colleagues become better at reading CT/MRI!

I feel like we are rarely formally tested with stacked images in medical school and real-life stacked images are rarely annotated adequately enough for us to realistically learn on our own without either constant cross referencing to a textbook or supervision by a more experienced colleague.

1 Like

@kleinerpirat What’s the best way to have the addeventlistener for wheel trigger the scrollable stack of images but not the page in the case that my image is larger than my screen (may have extra images in the answer, just want it bigger / more zoomed in for clarity, etc.)?

I looked at this and figured I would ask a pro before going down the rabbit hole: javascript - Prevent page scrolling when mouse is over one particular div - Stack Overflow

Can also upload the deck with the current template I’m now using once I have better internet connection. Thanks!

Thank you so much for this solution! I’ve been searching for this for a while.

Does anyone have any idea how to implement something similar that works on Android based on touch scrolling? I tried to adapt some of the code from this link (A simple swipe detection on vanilla js · GitHub) but couldn’t get it to work. I think some modification of the below could be useful:

let touchstartX = 0;
let touchstartY = 0;
let touchendX = 0;
let touchendY = 0;

const gestureZone = document.getElementById('modalContent');

gestureZone.addEventListener('touchstart', function(event) {
    touchstartX = event.changedTouches[0].screenX;
    touchstartY = event.changedTouches[0].screenY;
}, false);

gestureZone.addEventListener('touchend', function(event) {
    touchendX = event.changedTouches[0].screenX;
    touchendY = event.changedTouches[0].screenY;
    handleGesture();
}, false); 

function handleGesture() {
    if (touchendX < touchstartX) {
        console.log('Swiped left');
    }
    
    if (touchendX > touchstartX) {
        console.log('Swiped right');
    }
    
    if (touchendY < touchstartY) {
        console.log('Swiped up');
    }
    
    if (touchendY > touchstartY) {
       console.log('Swiped down');
    }
    
    if (touchendY === touchstartY) {
       console.log('Tap');
    }
}

I managed to cobble together the following code which adds functionality to scroll 1 frame at a time on AnkiDroid while preserving wheel scrolling on computer. Not ideal but it’s a starting point.

I also figured out how only the images stack scroll when hovering over images and card scroll when not hovering over images. It works better on PC and is inconsistent on Android. I’d welcome suggestions.

Much thanks to @kleinerpirat for your code!

<script>

/***Code to lock scroll***/
let scrollPosition;
let isMouseHover = false;
let scrollingImage = document.getElementById("images");

scrollingImage.addEventListener("mouseleave", function (event) {
    isMouseHover = false;
}, false);
scrollingImage.addEventListener("mouseover", function (event) {
    isMouseHover = true;
    scrollPosition = window.pageYOffset;
}, false);

scrollingImage.addEventListener("touchstart", function (event) {
    document.body.style.overflow = "hidden";
}, false);

function scrollCheckPC() {
    if (isMouseHover) {
        document.body.style.overflow = "hidden";
    } else {
        document.body.style.overflow = "auto";
    }
}




/***PC***/
(() => {
    let activeImg;
    qImg = document.querySelector("#img > img");
    const images = document.querySelectorAll("#images img");
    for (const img of images) {
        const isActive = img.src === qImg.src;
        img.classList.toggle("active", isActive);
        if (isActive) {
            activeImg = img;
        }
    }

    document.getElementById("qa").addEventListener("wheel", (e) => {
        const nextImg = activeImg[e.deltaY > 0 ? "nextElementSibling" : "previousElementSibling"];
        if (!nextImg) {
            return;
        }
        if (isMouseHover) {
            activeImg.classList.remove("active");
            nextImg.classList.add("active");
            activeImg = nextImg;
            window.scrollTo(0, scrollPosition);
        }
        scrollCheckPC();
    });


/***ANDROID***/
let touchstartX = 0;
let touchstartY = 0;
let touchmovingX = 0;
let touchmovingY = 0;
let touchendX = 0;
let touchendY = 0;

document.getElementById("qa").addEventListener('touchstart', function(event) {
    touchstartX = event.changedTouches[0].screenX;
    touchstartY = event.changedTouches[0].screenY;
}, false);

document.getElementById("qa").addEventListener('touchend', function(event) {
    touchendX = event.changedTouches[0].screenX;
    touchendY = event.changedTouches[0].screenY;
    handleGesture();
    document.body.style.overflow = "auto";
}, false); 

function handleGesture() { 
    const nextImg = activeImg[touchstartY < touchendY ? "nextElementSibling" : "previousElementSibling"];
    if (!nextImg) {
         return;
    }
    activeImg.classList.remove("active");
    nextImg.classList.add("active");
    activeImg = nextImg;
    }

})();

</script>

After many hours that were perhaps better spent actually studying, I have a working card template! It is tested on both Ankidroid and Anki PC. When you scroll/swipe an image stack, only that stack scrolls. When you scroll/swipe outside of all image stacks, only the page scrolls.

You can add up to 4 image stacks. If you need fewer than that, you can leave the associated fields blank.

Link to download: Scrollable Image Stack (CT_MRI)

Relevant code below. Note this is for the back. For the front, I had to change all the variables (i.e. add __Front) to get rid of some strange conflict that was causing the scroll behavior to be wonky.

<div id="img" hidden>{{Frame}}</div>
<div id="images" class="div-1">{{Sequence}}</div>
<div id="img2" hidden>{{Frame2}}</div>
<div id="images2" class="div-1">{{Sequence2}}</div>
<div id="img3" hidden>{{Frame3}}</div>
<div id="images3" class="div-1">{{Sequence3}}</div>
<div id="img4" hidden>{{Frame4}}</div>
<div id="images4" class="div-1">{{Sequence4}}</div>

{{Back}}
<hr  id="ans">


<script>
(() => {

/***PC***/
let isMouseHover = false;
let scrollingImage = document.getElementById("images");

scrollingImage.addEventListener("mouseleave", function (event) {
    isMouseHover = false;
    document.body.style.overflow = "auto";
}, false);
scrollingImage.addEventListener("mouseover", function (event) {
    isMouseHover = true;
    document.body.style.overflow = "hidden";
}, false);

let isMouseHover2 = false;
let scrollingImage2 = document.getElementById("images2");

scrollingImage2.addEventListener("mouseleave", function (event) {
    isMouseHover2 = false;
    document.body.style.overflow = "auto";
}, false);
scrollingImage2.addEventListener("mouseover", function (event) {
    isMouseHover2 = true;
    document.body.style.overflow = "hidden";
}, false);

let isMouseHover3 = false;
let scrollingImage3 = document.getElementById("images3");

scrollingImage3.addEventListener("mouseleave", function (event) {
    isMouseHover3 = false;
    document.body.style.overflow = "auto";
}, false);
scrollingImage3.addEventListener("mouseover", function (event) {
    isMouseHover3 = true;
    document.body.style.overflow = "hidden";
}, false);

let isMouseHover4 = false;
let scrollingImage4 = document.getElementById("images4");

scrollingImage4.addEventListener("mouseleave", function (event) {
    isMouseHover4 = false;
    document.body.style.overflow = "auto";
}, false);
scrollingImage4.addEventListener("mouseover", function (event) {
    isMouseHover4 = true;
    document.body.style.overflow = "hidden";
}, false);


    let activeImg;
    qImg = document.querySelector("#img > img");
    const images = document.querySelectorAll("#images img");
    for (const img of images) {
        const isActive = img.src === qImg.src;
        img.classList.toggle("active", isActive);
        if (isActive) {
            activeImg = img;
        }
    }

    let activeImg2;
    qImg2 = document.querySelector("#img2 > img");
    const images2 = document.querySelectorAll("#images2 img");
    for (const img2 of images2) {
        const isActive2 = img2.src === qImg2.src;
        img2.classList.toggle("active", isActive2);
        if (isActive2) {
            activeImg2 = img2;
        }
    }

    let activeImg3;
    qImg3 = document.querySelector("#img3 > img");
    const images3 = document.querySelectorAll("#images3 img");
    for (const img3 of images3) {
        const isActive3 = img3.src === qImg3.src;
        img3.classList.toggle("active", isActive3);
        if (isActive3) {
            activeImg3 = img3;
        }
    }

    let activeImg4;
    qImg4 = document.querySelector("#img4 > img");
    const images4 = document.querySelectorAll("#images4 img");
    for (const img4 of images4) {
        const isActive4 = img4.src === qImg4.src;
        img4.classList.toggle("active", isActive4);
        if (isActive4) {
            activeImg4 = img4;
        }
    }

    document.getElementById("qa").addEventListener("wheel", (e) => {
        if (isMouseHover) {        
            const nextImg = activeImg[e.deltaY > 0 ? "nextElementSibling" : "previousElementSibling"];
            if (!nextImg) {
                return;
            }
            activeImg.classList.remove("active");
            nextImg.classList.add("active");
            activeImg = nextImg;
        } else if (isMouseHover2) {
            const nextImg2 = activeImg2[e.deltaY > 0 ? "nextElementSibling" : "previousElementSibling"];
            if (!nextImg2) {
                return;
            }
            activeImg2.classList.remove("active");
            nextImg2.classList.add("active");
            activeImg2 = nextImg2;
        } else if (isMouseHover3) {
            const nextImg3 = activeImg3[e.deltaY > 0 ? "nextElementSibling" : "previousElementSibling"];
            if (!nextImg3) {
                return;
            }
            activeImg3.classList.remove("active");
            nextImg3.classList.add("active");
            activeImg3 = nextImg3;
        } else if (isMouseHover4) {
            const nextImg4 = activeImg4[e.deltaY > 0 ? "nextElementSibling" : "previousElementSibling"];
            if (!nextImg4) {
                return;
            }
            activeImg4.classList.remove("active");
            nextImg4.classList.add("active");
            activeImg4 = nextImg4;
        }
    });




/***ANDROID***/
let touchstartX = 0;
let touchstartY = 0;
let touchmovinglastX = 0;
let touchmovinglastY = 0;
let touchmovingX = 0;
let touchmovingY = 0;
let touchendX = 0;
let touchendY = 0;

let pixelCount = 10;

scrollingImage.addEventListener('touchstart', function(event) {
    touchstartX = event.changedTouches[0].screenX;
    touchstartY = event.changedTouches[0].screenY;
    touchmovinglastX = event.changedTouches[0].screenX;
    touchmovinglastY = event.changedTouches[0].screenY;
    document.body.style.overflow = "hidden";
}, false);

scrollingImage.addEventListener('touchmove', function(event) {
    touchmovingX = event.changedTouches[0].screenX;
    touchmovingY = event.changedTouches[0].screenY;
    handleGesture();
}, false);

scrollingImage.addEventListener('touchend', function(event) {
    touchendX = event.changedTouches[0].screenX;
    touchendY = event.changedTouches[0].screenY;
    document.body.style.overflow = "auto";
}, false); 

function handleGesture() { 
    if (Math.abs(touchmovingY - touchmovinglastY) > pixelCount) {
        const nextImg = activeImg[touchmovingY > touchmovinglastY ? "nextElementSibling" : "previousElementSibling"];
        if (!nextImg) {
              return;
        }
        activeImg.classList.remove("active");
        nextImg.classList.add("active");
        activeImg = nextImg;
        touchmovinglastX = touchmovingX;
        touchmovinglastY = touchmovingY;
    }
}


let touchstartX2 = 0;
let touchstartY2 = 0;
let touchmovinglastX2 = 0;
let touchmovinglastY2 = 0;
let touchmovingX2 = 0;
let touchmovingY2 = 0;
let touchendX2 = 0;
let touchendY2 = 0;

scrollingImage2.addEventListener('touchstart', function(event) {
    touchstartX2 = event.changedTouches[0].screenX;
    touchstartY2 = event.changedTouches[0].screenY;
    touchmovinglastX2 = event.changedTouches[0].screenX;
    touchmovinglastY2 = event.changedTouches[0].screenY;
    document.body.style.overflow = "hidden";
}, false);

scrollingImage2.addEventListener('touchmove', function(event) {
    touchmovingX2 = event.changedTouches[0].screenX;
    touchmovingY2 = event.changedTouches[0].screenY;
    handleGesture2();
}, false);

scrollingImage2.addEventListener('touchend', function(event) {
    touchendX2 = event.changedTouches[0].screenX;
    touchendY2 = event.changedTouches[0].screenY;
    document.body.style.overflow = "auto";
}, false); 

function handleGesture2() { 
    if (Math.abs(touchmovingY2 - touchmovinglastY2) > pixelCount) {
        const nextImg2 = activeImg2[touchmovingY2 > touchmovinglastY2 ? "nextElementSibling" : "previousElementSibling"];
        if (!nextImg2) {
              return;
        }
        activeImg2.classList.remove("active");
        nextImg2.classList.add("active");
        activeImg2 = nextImg2;
        touchmovinglastX2 = touchmovingX2;
        touchmovinglastY2 = touchmovingY2;
    }
}


let touchstartX3 = 0;
let touchstartY3 = 0;
let touchmovinglastX3 = 0;
let touchmovinglastY3 = 0;
let touchmovingX3 = 0;
let touchmovingY3 = 0;
let touchendX3 = 0;
let touchendY3 = 0;

scrollingImage3.addEventListener('touchstart', function(event) {
    touchstartX3 = event.changedTouches[0].screenX;
    touchstartY3 = event.changedTouches[0].screenY;
    touchmovinglastX3 = event.changedTouches[0].screenX;
    touchmovinglastY3 = event.changedTouches[0].screenY;
    document.body.style.overflow = "hidden";
}, false);

scrollingImage3.addEventListener('touchmove', function(event) {
    touchmovingX3 = event.changedTouches[0].screenX;
    touchmovingY3 = event.changedTouches[0].screenY;
    handleGesture3();
}, false);

scrollingImage3.addEventListener('touchend', function(event) {
    touchendX3 = event.changedTouches[0].screenX;
    touchendY3 = event.changedTouches[0].screenY;
    document.body.style.overflow = "auto";
}, false); 

function handleGesture3() { 
    if (Math.abs(touchmovingY3 - touchmovinglastY3) > pixelCount) {
        const nextImg3 = activeImg3[touchmovingY3 > touchmovinglastY3 ? "nextElementSibling" : "previousElementSibling"];
        if (!nextImg3) {
              return;
        }
        activeImg3.classList.remove("active");
        nextImg3.classList.add("active");
        activeImg3 = nextImg3;
        touchmovinglastX3 = touchmovingX3;
        touchmovinglastY3 = touchmovingY3;
    }
}


let touchstartX4 = 0;
let touchstartY4 = 0;
let touchmovinglastX4 = 0;
let touchmovinglastY4 = 0;
let touchmovingX4 = 0;
let touchmovingY4 = 0;
let touchendX4 = 0;
let touchendY4 = 0;

scrollingImage4.addEventListener('touchstart', function(event) {
    touchstartX4 = event.changedTouches[0].screenX;
    touchstartY4 = event.changedTouches[0].screenY;
    touchmovinglastX4 = event.changedTouches[0].screenX;
    touchmovinglastY4 = event.changedTouches[0].screenY;
    document.body.style.overflow = "hidden";
}, false);

scrollingImage4.addEventListener('touchmove', function(event) {
    touchmovingX4 = event.changedTouches[0].screenX;
    touchmovingY4 = event.changedTouches[0].screenY;
    handleGesture4();
}, false);

scrollingImage4.addEventListener('touchend', function(event) {
    touchendX4 = event.changedTouches[0].screenX;
    touchendY4 = event.changedTouches[0].screenY;
    document.body.style.overflow = "auto";
}, false); 

function handleGesture4() { 
    if (Math.abs(touchmovingY4 - touchmovinglastY4) > pixelCount) {
        const nextImg4 = activeImg4[touchmovingY4 > touchmovinglastY4 ? "nextElementSibling" : "previousElementSibling"];
        if (!nextImg4) {
              return;
        }
        activeImg4.classList.remove("active");
        nextImg4.classList.add("active");
        activeImg4 = nextImg4;
        touchmovinglastX4 = touchmovingX4;
        touchmovinglastY4 = touchmovingY4;
    }
}

})();
</script>

Necessary CSS style code:

#images img {
  display: none;
}
#images img.active {
  display: inline;
  margin: 0 auto;
}

#images2 img {
  display: none;
}
#images2 img.active {
  display: inline;
  margin: 0 auto;
}

#images3 img {
  display: none;
}
#images3 img.active {
  display: inline;
  margin: 0 auto;
}

#images4 img {
  display: none;
}
#images4 img.active {
  display: inline;
  margin: 0 auto;
}

.div-1 {
	display: inline-block;
}

I have no formal training in computer programming. This was an amalgamation of many threads on stackoverflow etc, so please excuse any code inefficiencies!

2 Likes

Very nice! What’s the purpose of the div-1 class? Thanks!

No problem! Before implementing that div-1 class, each image stack would “take up” the entire width of the screen despite the image only being say 500px wide. One negative effect of this was that for cards with multiple image stacks, each image stack would show on a new line even if there was space to the right. The other even less desirable effect was that if you hovered the mouse anywhere on or to the right of the image stack, it would register as being inside the image stack, and the image stack would scroll. The div-1 was how I fixed those problems.

1 Like

This topic was automatically closed 30 days after the last reply. New replies are no longer allowed.