kahuna/public/js/components/gu-lazy-table/gu-lazy-table.js (262 lines of code) (raw):
import angular from 'angular';
import Rx from 'rx';
import 'rx-dom';
import 'javascript-detect-element-resize';
import './gu-lazy-table-cell';
import './gu-lazy-table-placeholder';
import '../../util/rx';
import '../../util/seq';
/* global addResizeListener */
/* global removeResizeListener */
import {
combine$,
add$, sub$, mult$, div$, mod$,
floor$, ceil$, max$, min$, round$
} from './observable-utils';
export var lazyTable = angular.module('gu.lazyTable', [
'gu.lazyTableCell',
'gu.lazyTablePlaceholder',
'util.rx',
'util.seq'
]);
// Set in ElasticSearchModel.scala
const maxSize = 200;
function asInt(string) {
return parseInt(string, 10);
}
function findIndexFrom(array, item, fromIndex) {
for (let i = fromIndex, len = array.length; i < len; i++) {
if (array[i] === item) {
return i;
}
}
return -1;
}
function findLastIndexFrom(array, item, fromIndex) {
for (let i = fromIndex; i >= 0; i--) {
if (array[i] === item) {
return i;
}
}
return -1;
}
lazyTable.controller('GuLazyTableCtrl', ['range', function(range) {
let ctrl = this;
ctrl.init = function({items$, preloadedRows$, cellHeight$, cellMinWidth$,
containerWidth$, viewportHeight$, viewportTop$}) {
const itemsCount$ = items$.map(items => items.length).distinctUntilChanged();
const columns$ = max$(floor$(div$(containerWidth$, cellMinWidth$)), 1);
const rows$ = ceil$(div$(itemsCount$, columns$));
const cellWidth$ = floor$(div$(containerWidth$, columns$));
const viewportBottom$ = add$(viewportTop$, viewportHeight$);
const currentRowTop$ = round$(div$(viewportTop$, cellHeight$));
const currentRowBottom$ = round$(div$(viewportBottom$, cellHeight$));
const loadedRowTop$ = max$(sub$(currentRowTop$, preloadedRows$), 0).
distinctUntilChanged();
const loadedRowBottom$ = combine$(currentRowBottom$, preloadedRows$, rows$,
(currentRowBottom, preloadedRows, rows) => {
return Math.min(currentRowBottom + preloadedRows, rows - 1);
}).distinctUntilChanged();
const loadedRangeStart$ = mult$(loadedRowTop$, columns$);
const loadedRangeEnd$ = combine$(loadedRowBottom$, columns$, itemsCount$,
(loadedRowBottom, columns, itemsCount) => {
const endRowItemIndex = ((loadedRowBottom + 1) * columns) - 1;
return Math.min(Math.max(endRowItemIndex, 0), itemsCount);
});
const rangeToLoad$ = combine$(
items$, loadedRangeStart$, loadedRangeEnd$,
(items, loadedRangeStart, loadedRangeEnd) => {
const $start = findIndexFrom(items, undefined, loadedRangeStart);
const $end = findLastIndexFrom(items, undefined, loadedRangeEnd);
return {$start, $end};
}
).
// Debounce range loading, which also helps discard
// erroneous large ranges while combining
// loadedRangeStart$ and loadedRangeEnd$ changes (one after the other)
debounce(10).
// Ignore if either end isn't set (whole range already loaded)
filter(({$start, $end}) => $start !== -1 && $end !== -1).
// Ignore if $start after $end (incomplete combine$ state)
filter(({$start, $end}) => $start <= $end).
// Max query size
map(({$start, $end})=> {
if (($end - $start) < maxSize) {
return {$start, $end};
}
return {$start, $end: $start + maxSize - 1};
}).
distinctUntilChanged(({$start, $end}) => `${$start}-${$end}`);
// Placeholders
const placeholderExtraCount$ = mult$(columns$, preloadedRows$);
const placeholderRangeStart$ = max$(sub$(loadedRangeStart$, placeholderExtraCount$), 0);
const placeholderRangeEnd$ = min$(add$(loadedRangeEnd$, placeholderExtraCount$),
sub$(itemsCount$, 1));
const placeholderIndexes$ = combine$(
placeholderRangeStart$, placeholderRangeEnd$,
(placeholderRangeStart, placeholderRangeEnd) => {
let indexes = range(placeholderRangeStart, placeholderRangeEnd);
return Array.from(indexes);
}
);
const viewHeight$ = mult$(rows$, cellHeight$);
// Mutations needed here to access streams in this closure ;_;
// Share subscriptions to these streams between all cells and
// placeholders that register to their position
const itemsShared$ = items$.shareReplay(1);
const cellWidthShared$ = cellWidth$.shareReplay(1);
const cellHeightShared$ = cellHeight$.shareReplay(1);
const columnsShared$ = columns$.shareReplay(1);
const preloadedRowsShared$ = preloadedRows$.shareReplay(1);
const viewportTopShared$ = viewportTop$.shareReplay(1);
const viewportBottomShared$ = viewportBottom$.shareReplay(1);
ctrl.getItemPosition$ = createGetItemPosition$({
items$: itemsShared$,
cellWidth$: cellWidthShared$,
cellHeight$: cellHeightShared$,
columns$: columnsShared$,
preloadedRows$: preloadedRowsShared$,
viewportTop$: viewportTopShared$,
viewportBottom$: viewportBottomShared$
});
ctrl.getCellPosition$ = createGetCellPosition$({
cellWidth$: cellWidthShared$,
cellHeight$: cellHeightShared$,
columns$: columnsShared$,
preloadedRows$: preloadedRowsShared$,
viewportTop$: viewportTopShared$,
viewportBottom$: viewportBottomShared$
});
const rowsInViewport$ = max$(floor$(div$(viewportHeight$, cellHeight$)), 1);
// Marginally satisfying pattern to convert from imperative to
// reactive land
const scrollCommands$ = Rx.Observable.create(observer => {
ctrl.scrollPrevRow = () => observer.onNext('prevRow');
ctrl.scrollNextRow = () => observer.onNext('nextRow');
ctrl.scrollPrevPage = () => observer.onNext('prevPage');
ctrl.scrollNextPage = () => observer.onNext('nextPage');
ctrl.scrollStart = () => observer.onNext('start');
ctrl.scrollEnd = () => observer.onNext('end');
});
const rowOffset$ = scrollCommands$.withLatestFrom(
rowsInViewport$, currentRowTop$, rows$,
(command, rowsInViewport, currentRowTop, rows) => {
return {
prevRow: - 1,
nextRow: + 1,
prevPage: - rowsInViewport,
nextPage: + rowsInViewport,
start: - currentRowTop,
end: rows - currentRowTop
}[command] || 0;
});
const newScrollTop$ = rowOffset$.withLatestFrom(currentRowTop$, cellHeight$,
(rowOffset, currentRowTop, cellHeight) => {
return (currentRowTop + rowOffset) * cellHeight;
});
return {
viewHeight$, rangeToLoad$, placeholderIndexes$, newScrollTop$
};
};
function createGetCellPosition$({cellWidth$, cellHeight$, columns$,
preloadedRows$, viewportTop$, viewportBottom$}) {
const width$ = cellWidth$;
const height$ = cellHeight$;
const loadedHeight$ = mult$(preloadedRows$, height$);
return (index) => {
const top$ = mult$(floor$(div$(index, columns$)), height$);
const left$ = mult$(mod$(index, columns$), width$);
const bottom$ = add$(top$, height$);
const display$ = combine$(top$, bottom$, loadedHeight$,
viewportTop$, viewportBottom$,
(top, bottom, loadedHeight,
viewportTop, viewportBottom) => {
// TODO: allow this to be configured
const unloadHeight = 2 * loadedHeight;
return (top > viewportTop - unloadHeight &&
bottom < viewportBottom + unloadHeight) ?
'block' :
'none';
});
return combine$(top$, left$, width$, height$, display$,
(top, left, width, height, display) => {
return ({top, left, width, height, display});
}).
distinctUntilChanged(({top, left, width, height, display}) =>
`${top}-${left}-${width}-${height}-${display}`);
};
}
function createGetItemPosition$({items$, cellWidth$, cellHeight$, columns$,
preloadedRows$, viewportTop$, viewportBottom$}) {
return (item) => {
// share() because it's an expensive operation
const index$ = items$.map(items => items.indexOf(item)).
distinctUntilChanged().
share();
const getPos$ = createGetCellPosition$({
cellWidth$, cellHeight$, columns$,
preloadedRows$, viewportTop$, viewportBottom$
});
return index$.flatMap(getPos$);
};
}
}]);
lazyTable.directive('guLazyTable', ['$window', 'observe$',
'observeCollection$', 'subscribe$',
function($window, observe$,
observeCollection$, subscribe$) {
return {
restrict: 'A',
controller: 'GuLazyTableCtrl',
transclude: true,
template: `
<ul>
<li ng:repeat="placeholderIndex in $placeholders"
class="result-placeholder"
gu:lazy-table-placeholder="placeholderIndex"></li>
</ul>
<ng-transclude></ng-transclude>
`,
link: function (scope, element, attrs, ctrl) {
// Map attributes as Observable streams
const {
guLazyTable: itemsAttr,
guLazyTableLoadRange: loadRangeFn,
guLazyTableCellMinWidth: cellMinWidthAttr,
guLazyTableCellHeight: cellHeightAttr,
guLazyTablePreloadedRows: preloadedRowsAttr
} = attrs;
const items$ = observeCollection$(scope, itemsAttr);
const cellMinWidth$ = observe$(scope, cellMinWidthAttr).map(asInt);
const cellHeight$ = observe$(scope, cellHeightAttr).map(asInt);
const preloadedRows$ = observe$(scope, preloadedRowsAttr).map(asInt);
// Observe events affecting the view
const viewportScrolled$ = Rx.DOM.fromEvent($window, 'scroll').
// delay fine-tuned to roughly match 'slow scrolling'
// speed but not fast scrolling
debounce(80).
startWith({/* init */});
const viewportResized$ = Rx.DOM.fromEvent($window, 'resize').
debounce(100).
startWith({/* init */});
// Element resized (possibly not the viewport, e.g. side-panel expanded)
const elementResized$ = Rx.Observable.fromEventPattern(
handler => addResizeListener(element[0], handler),
handler => removeResizeListener(element[0], handler)
).
startWith({/* init */});
// Model container and viewport properties
// Offset between container top and top of scrolling area (page)
// Read once as we assume it never changes
const containerTop = element[0].getClientRects()[0].top;
const containerWidth$ = combine$(
viewportResized$,
elementResized$,
() => element[0].clientWidth
).shareReplay(1);
const offsetTop$ = viewportScrolled$.map(() => {
// For Chrome we need to read scrollTop on body, for
// other browser it's on the documentElement. Meh.
// https://miketaylr.com/posts/2014/11/document-body-scrollTop.html
return document.body.scrollTop || document.documentElement.scrollTop;
}).shareReplay(1);
const offsetHeight$ = combine$(viewportScrolled$, viewportResized$, () => {
return Math.max(document.documentElement.clientHeight - containerTop, 0);
}).shareReplay(1);
const viewportTop$ = offsetTop$;
const viewportHeight$ = offsetHeight$;
const {viewHeight$, rangeToLoad$, placeholderIndexes$, newScrollTop$} = ctrl.init({
items$, preloadedRows$, cellHeight$, cellMinWidth$,
containerWidth$, viewportTop$, viewportHeight$
});
// Table cells will be absolutely positioned within this container
element.css({position: 'relative'});
subscribe$(scope, rangeToLoad$, range => {
scope.$eval(loadRangeFn, range);
});
subscribe$(scope, placeholderIndexes$, indexes => {
scope.$placeholders = indexes;
});
subscribe$(scope, viewHeight$.distinctUntilChanged(), viewHeight => {
// Delay application until after this cycle, possibly
// with other cell rendering updates
scope.$applyAsync(() => {
element.css('height', viewHeight + 'px');
scope.$emit('gu-lazy-table:height-changed', viewHeight);
});
});
subscribe$(scope, newScrollTop$, newScrollTop => {
// Note: it may be negative or beyond the page height,
// but the browser will normalise that anyway
window.scrollTo(0, newScrollTop);
});
}
};
}]);