Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Performance: Full virtualization of the mosaic view #3436

Open
wants to merge 14 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions frontend/src/common/virtualization-tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,64 @@ export const virtualizationTools = {

return [firstVisibleElementIndex, lastVisibileElementIndex];
},

getVisibleRange: (
elements,
containerWidth,
containerHeight,
elementWidth,
elementHeight,
scrollPos,
rowPadding = 0
) => {
// floor the elementWidth, so that rounding errors don't cause the columnCount
// to be one to small
const visibleColumnCount = Math.floor(containerWidth / Math.floor(elementWidth));

const totalRowCount = Math.ceil(elements.length / visibleColumnCount);
const totalScrollHeight = totalRowCount * elementHeight;
const realScrollPos = Math.max(Math.min(scrollPos, totalScrollHeight), 0);

/**
* in a previous version, we calculated the actual visible row count by first
* calculating the firstRowTopOverlap (= realScrollPos % elementHeight)
* and adding that to the containerHeight.
*
* this would cause the visibleRowCount to sometimes be 1 higher, sometimes
* 1 smaller than the last run, depending on the actual scroll position.
*
* That resulted in this function not always returning the same number of
* rows. We want to always return the same number of rows however, because
* that allowes reduces the amount of updates in scenarios where half a row
* is visible. The result is, that removing and adding a row on scroll always
* happen in the same render, instead of in two renders
*/
const visibleRowCount = Math.ceil(containerHeight / elementHeight) + 1;

const firstVisibleRow = Math.floor(realScrollPos / elementHeight);
const lastVisibleRow = firstVisibleRow + visibleRowCount - 1;

const firstRowToRender = firstVisibleRow - rowPadding;
const lastRowToRender = lastVisibleRow + rowPadding;

const firstElementToRender = Math.max(firstRowToRender * visibleColumnCount, 0);
// eslint-disable-next-line prettier-vue/prettier
const lastElementToRender = Math.min((lastRowToRender + 1) * visibleColumnCount - 1, elements.length - 1);

return {
visibleColumnCount,
firstElementToRender,
lastElementToRender,
totalScrollHeight,
};
},

getVirtualizedElementStyle: (index, columnCount, elementWidth, elementHeight) => {
const rowIndex = Math.floor(index / columnCount);
const columnIndex = index % columnCount;
const topPos = rowIndex * elementHeight;
const leftPos = columnIndex * elementWidth;

return `display: block; position: absolute; width: ${elementWidth}px; height: ${elementHeight}px; transform: translate(${leftPos}px, ${topPos}px)`;
},
};
272 changes: 155 additions & 117 deletions frontend/src/component/photo/mosaic.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>
<v-container grid-list-xs fluid class="pa-2 p-photos p-photo-mosaic">
<template v-if="photos.length === 0">
<template v-if="visiblePhotos.length === 0">
<v-alert
:value="true"
color="secondary-dark"
Expand All @@ -25,106 +25,96 @@
</p>
</v-alert>
</template>
<v-layout row wrap class="search-results photo-results mosaic-view" :class="{'select-results': selectMode}">
<v-layout ref="container" row wrap class="search-results photo-results mosaic-view" :class="{'select-results': selectMode}" :style="`position: relative; height: ${scrollHeight}px`">
<div
v-for="(photo, index) in photos"
v-for="(photo, index) in visiblePhotos"
ref="items"
:key="photo.ID"
class="flex xs4 sm3 md2 lg1"
class="flex xs4 sm3 md2 lg1 image-container"
:data-index="index"
:style="getVirtualizedElementStyle(parseInt(index))"
>
<!--
The following div is the layout + size container. It makes the browser not
re-layout all elements in the list when the children of one of them changes
-->
<div class="image-container">
<div v-if="index < firstVisibleElementIndex || index > lastVisibileElementIndex"
:data-uid="photo.UID"
class="card darken-1 result image"
/>
<div v-else
:key="photo.Hash"
tile
:data-id="photo.ID"
:data-uid="photo.UID"
:style="`background-image: url(${photo.thumbnailUrl('tile_224')})`"
:class="photo.classes().join(' ') + ' card darken-1 result clickable image'"
:alt="photo.Title"
:title="photo.Title"
@contextmenu.stop="onContextMenu($event, index)"
@touchstart.passive="input.touchStart($event, index)"
@touchend.stop.prevent="onClick($event, index)"
@mousedown.stop.prevent="input.mouseDown($event, index)"
@click.stop.prevent="onClick($event, index)"
@mouseover="playLive(photo)"
@mouseleave="pauseLive(photo)">
<v-layout v-if="photo.Type === 'live' || photo.Type === 'animated'" class="live-player">
<video :id="'live-player-' + photo.ID" :key="photo.ID" width="224" height="224" preload="none"
loop muted playsinline>
<source :src="photo.videoUrl()">
</video>
</v-layout>
<div :key="photo.Hash"
tile
:data-id="photo.ID"
:data-uid="photo.UID"
:style="`background-image: url(${photo.thumbnailUrl('tile_224')})`"
:class="photo.classes().join(' ') + ' card darken-1 result clickable image'"
:alt="photo.Title"
:title="photo.Title"
@contextmenu.stop="onContextMenu($event, parseInt(index))"
@touchstart.passive="input.touchStart($event, parseInt(index))"
@touchend.stop.prevent="onClick($event, parseInt(index))"
@mousedown.stop.prevent="input.mouseDown($event, parseInt(index))"
@click.stop.prevent="onClick($event, parseInt(index))"
@mouseover="playLive(photo)"
@mouseleave="pauseLive(photo)">
<v-layout v-if="photo.Type === 'live' || photo.Type === 'animated'" class="live-player">
<video :id="'live-player-' + photo.ID" :key="photo.ID" width="224" height="224" preload="none"
loop muted playsinline>
<source :src="photo.videoUrl()">
</video>
</v-layout>

<button v-if="photo.Type !== 'image' || photo.Files.length > 1"
class="input-open"
@touchstart.stop.prevent="input.touchStart($event, index)"
@touchend.stop.prevent="onOpen($event, index, !isSharedView, photo.Type === 'live')"
@touchmove.stop.prevent
@click.stop.prevent="onOpen($event, index, !isSharedView, photo.Type === 'live')">
<i v-if="photo.Type === 'raw'" class="action-raw" :title="$gettext('RAW')">raw_on</i>
<i v-if="photo.Type === 'live'" class="action-live" :title="$gettext('Live')"><icon-live-photo/></i>
<i v-if="photo.Type === 'video'" class="action-play" :title="$gettext('Video')">play_arrow</i>
<i v-if="photo.Type === 'animated'" class="action-animated" :title="$gettext('Animated')">gif</i>
<i v-if="photo.Type === 'vector'" class="action-vector" :title="$gettext('Vector')">font_download</i>
<i v-if="photo.Type === 'image'" class="action-stack" :title="$gettext('Stack')">burst_mode</i>
</button>

<button v-if="photo.Type === 'image' && selectMode"
class="input-view"
:title="$gettext('View')"
@touchstart.stop.prevent="input.touchStart($event, index)"
@touchend.stop.prevent="onOpen($event, index)"
@touchmove.stop.prevent
@click.stop.prevent="onOpen($event, index)">
<i color="white" class="action-fullscreen">zoom_in</i>
</button>
<button v-if="photo.Type !== 'image' || photo.Files.length > 1"
class="input-open"
@touchstart.stop.prevent="input.touchStart($event, parseInt(index))"
@touchend.stop.prevent="onOpen($event, parseInt(index), !isSharedView, photo.Type === 'live')"
@touchmove.stop.prevent
@click.stop.prevent="onOpen($event, parseInt(index), !isSharedView, photo.Type === 'live')">
<i v-if="photo.Type === 'raw'" class="action-raw" :title="$gettext('RAW')">raw_on</i>
<i v-if="photo.Type === 'live'" class="action-live" :title="$gettext('Live')"><icon-live-photo/></i>
<i v-if="photo.Type === 'video'" class="action-play" :title="$gettext('Video')">play_arrow</i>
<i v-if="photo.Type === 'animated'" class="action-animated" :title="$gettext('Animated')">gif</i>
<i v-if="photo.Type === 'vector'" class="action-vector" :title="$gettext('Vector')">font_download</i>
<i v-if="photo.Type === 'image'" class="action-stack" :title="$gettext('Stack')">burst_mode</i>
</button>

<button v-if="!isSharedView && hidePrivate && photo.Private" class="input-private">
<i color="white" class="select-on">lock</i>
</button>
<button v-if="photo.Type === 'image' && selectMode"
class="input-view"
:title="$gettext('View')"
@touchstart.stop.prevent="input.touchStart($event, parseInt(index))"
@touchend.stop.prevent="onOpen($event, parseInt(index))"
@touchmove.stop.prevent
@click.stop.prevent="onOpen($event, parseInt(index))">
<i color="white" class="action-fullscreen">zoom_in</i>
</button>

<!--
We'd usually use v-if here to only render the button if needed.
Because the button is supposed to be visible when the result is
being hovered over, implementing the v-if would require the use of
a <v-hover> element around the result.
<button v-if="!isSharedView && hidePrivate && photo.Private" class="input-private">
<i color="white" class="select-on">lock</i>
</button>

Because rendering the plain HTML-Button is faster than rendering
the v-hover component we instead hide the button by default and
use css to show it when it is being hovered.
-->
<button
class="input-select"
@mousedown.stop.prevent="input.mouseDown($event, index)"
@touchstart.stop.prevent="input.touchStart($event, index)"
@touchend.stop.prevent="onSelect($event, index)"
@touchmove.stop.prevent
@click.stop.prevent="onSelect($event, index)">
<i color="white" class="select-on">check_circle</i>
<i color="white" class="select-off">radio_button_off</i>
</button>
<!--
We'd usually use v-if here to only render the button if needed.
Because the button is supposed to be visible when the result is
being hovered over, implementing the v-if would require the use of
a <v-hover> element around the result.

<button v-if="!isSharedView"
class="input-favorite"
@touchstart.stop.prevent="input.touchStart($event, index)"
@touchend.stop.prevent="toggleLike($event, index)"
Because rendering the plain HTML-Button is faster than rendering
the v-hover component we instead hide the button by default and
use css to show it when it is being hovered.
-->
<button
class="input-select"
@mousedown.stop.prevent="input.mouseDown($event, parseInt(index))"
@touchstart.stop.prevent="input.touchStart($event, parseInt(index))"
@touchend.stop.prevent="onSelect($event, parseInt(index))"
@touchmove.stop.prevent
@click.stop.prevent="toggleLike($event, index)"
>
<i v-if="photo.Favorite">favorite</i>
<i v-else>favorite_border</i>
</button>
</div>
@click.stop.prevent="onSelect($event, parseInt(index))">
<i color="white" class="select-on">check_circle</i>
<i color="white" class="select-off">radio_button_off</i>
</button>

<button v-if="!isSharedView"
class="input-favorite"
@touchstart.stop.prevent="input.touchStart($event, parseInt(index))"
@touchend.stop.prevent="toggleLike($event, parseInt(index))"
@touchmove.stop.prevent
@click.stop.prevent="toggleLike($event, parseInt(index))"
>
<i v-if="photo.Favorite">favorite</i>
<i v-else>favorite_border</i>
</button>
</div>
</div>
</v-layout>
Expand Down Expand Up @@ -175,8 +165,16 @@ export default {
return {
hidePrivate: this.$config.settings().features.private,
input: new Input(),
firstVisibleElementIndex: 0,
lastVisibileElementIndex: 0,
firstElementToRender: 1,
lastElementToRender: 0,
visiblePhotos: {},
elementSize: 100, // no need to differentiate width and height, because the images are square.
containerWidth: 100,
containerHeight: window.innerHeight,
containerTop: 0,
scrollHeight: 100,
scrollPos: 0,
columnCount: 1,
visibleElementIndices: new Set(),
};
},
Expand All @@ -186,51 +184,91 @@ export default {
this.$nextTick(() => {
this.observeItems();
});
if (this.visiblePhotos[0] === undefined) {
this.visiblePhotos[0] = this.photos[0];
}
},
immediate: true,
}
},
beforeCreate() {
this.intersectionObserver = new IntersectionObserver((entries) => {
this.visibilitiesChanged(entries);
}, {
rootMargin: "50% 0px",
this.elementObserver = new ResizeObserver((entries) => {
this.elementSize = entries[0].borderBoxSize[0].inlineSize;
this.updateGeometry();
});
this.containerObserver = new ResizeObserver((entries) => {
this.containerWidth = entries[0].contentRect.width;
this.updateGeometry();
});
},
created() {
window.addEventListener('scroll', this.handleScroll);
window.addEventListener('resize', this.handleResize);
},
mounted() {
this.containerTop = this.$refs.container.getBoundingClientRect().top;
this.updateGeometry();
},
beforeDestroy() {
this.intersectionObserver.disconnect();
window.removeEventListener('scroll', this.handleScroll);
window.removeEventListener('resize', this.handleResize);
this.elementObserver.disconnect();
this.containerObserver.disconnect();
},
methods: {
observeItems() {
if (this.$refs.items === undefined) {
return;
}

/**
* observing only every 5th item reduces the amount of time
* spent computing intersection by 80%. me might render up to
* 8 items more than required, but the time saved computing
* intersections is far greater than the time lost rendering
* a couple more items
*/
for (let i = 0; i < this.$refs.items.length; i += 5) {
this.intersectionObserver.observe(this.$refs.items[i]);
this.elementObserver.observe(this.$refs.items[0]);
if (this.$refs.container !== undefined) {
this.containerObserver.observe(this.$refs.container);
}
},
elementIndexFromIntersectionObserverEntry(entry) {
return parseInt(entry.target.getAttribute('data-index'));
handleScroll(event) {
this.scrollPos = document.scrollingElement.scrollTop;
this.updateGeometry();
},
visibilitiesChanged(entries) {
const [smallestIndex, largestIndex] = virtualizationTools.updateVisibleElementIndices(
this.visibleElementIndices,
entries,
this.elementIndexFromIntersectionObserverEntry,
handleResize(event) {
this.containerHeight = window.innerHeight;
this.updateGeometry();
},
updateGeometry() {
const {
visibleColumnCount,
firstElementToRender,
lastElementToRender,
totalScrollHeight,
} = virtualizationTools.getVisibleRange(
this.photos,
this.containerWidth,
this.containerHeight,
this.elementSize,
this.elementSize,
this.scrollPos - this.containerTop,
2,
);

// we observe only every 5th item, so we increase the rendered
// range here by 4 items in every directio just to be safe
this.firstVisibleElementIndex = smallestIndex - 4;
this.lastVisibileElementIndex = largestIndex + 4;
this.columnCount = visibleColumnCount;
this.scrollHeight = totalScrollHeight;

if (this.firstElementToRender !== firstElementToRender || this.lastElementToRender !== lastElementToRender) {
this.firstElementToRender = firstElementToRender;
this.lastElementToRender = lastElementToRender;
this.visiblePhotos = {0: this.photos[0]};
for (let i = firstElementToRender; i <= lastElementToRender; i++) {
this.visiblePhotos[i] = this.photos[i];
}
}
},
getVirtualizedElementStyle(index) {
// the very fist element is not actually virtualized
if (index <= 0) {
return '';
}

return virtualizationTools.getVirtualizedElementStyle(index, this.columnCount, this.elementSize, this.elementSize);
},
livePlayer(photo) {
return document.querySelector("#live-player-" + photo.ID);
Expand Down