Skip to content

Instantly share code, notes, and snippets.

@sgratzl
Created July 12, 2017 20:03
Show Gist options
  • Save sgratzl/a37b3a0b9d903b3958648a6c41ffb734 to your computer and use it in GitHub Desktop.
Save sgratzl/a37b3a0b9d903b3958648a6c41ffb734 to your computer and use it in GitHub Desktop.
VWqJQq
<div class="lu">
<header>
<article></article>
<div class="slopegraph"></div>
</header>
<main>
<article></article>
<svg class="slopegraph"></svg>
</main>
</div>
const root = document.querySelector('div.lu');
const header = root.querySelector('header');
const body = root.querySelector('main');
const ranking = body.querySelector('article');
const rankingHeader = header.querySelector('article');
const slopeGraph = body.querySelector('.slopegraph');
root.appendChild(document.createElement('style'));
const stylesheet = root.lastElementChild.sheet;
const isEdge = typeof CSS !== 'undefined' && CSS.supports("(-ms-ime-align:auto)");
if (isEdge) {
root.classList.add('ms-edge');
}
//sync scrolling of header and body
let old = body.scrollTop;
header.scrollLeft = body.scrollLeft;
body.onscroll = (evt) => {
//TODO based on scroll left decide whether certain rankings should be rendered or updated
const scrollLeft = evt.target.scrollLeft;
if (header.scrollLeft !== scrollLeft) {
header.scrollLeft = evt.target.scrollLeft;
shiftFrozenColumns(columns, evt.target.scrollLeft);
}
const top = evt.target.scrollTop;
if (old !== top) {
const isGoingDown = top > old;
old = top;
onScrolled(top, evt.target.offsetHeight, isGoingDown, evt.target.scrollLeft);
}
}
class Column {
constructor(i, name, frozen, width) {
this.index = i;
this.id = `col${i}`;
this.name = name;
this.width = width || '100px';
this.frozen = frozen === true;
}
common() {
const d = document.createElement('div');
if (this.frozen) {
d.classList.add('frozen');
}
d.dataset.id = this.id;
if (isEdge) {
d.style.msGridColumn = this.index + 1;
} else {
d.style.gridColumnStart = this.id;
}
return d;
}
header() {
const d = this.common();
d.textContent = `Header ${this.name}`;
return d;
}
cell(row) {
const d = this.common();
d.textContent = `${this.name}@${row}`;
return d;
}
update(d, row) {
d.textContent = `${this.name}@${row}`;
return d;
}
}
const columns = [
new Column(0, 'A', true, '200px'),
new Column(1, 'B'),
new Column(2, 'C', true, '150px'),
new Column(3, 'D'),
new Column(4, 'E'),
new Column(5, 'F'),
new Column(6, 'G'),
new Column(7, 'H'),
new Column(8, 'I')
];
const rows = 1e+5;
const rowHeight = 20;
const rowExceptions = new Map(); //[[10, '50px'], [40, '300px']]);
function createRow(row, columns) {
const r = document.createElement('div');
r.dataset.row = row;
columns.forEach((col) => r.appendChild(col.cell(row)));
return r;
}
function updateRow(r, row, columns) {
r.dataset.row = row;
columns.forEach((col, i) => col.update(r.children[i], row));
return r;
}
const repeat = (count, width) => isEdge ? `(${width})[${count}]` : `repeat(${count}, ${width})`;
function compute(exceptions, defaultWidth, total) {
if (exceptions.size === 0) {
if (!isEdge) {
return defaultWidth; //use the auto feature
} else {
return repeat(total, defaultWidth);
}
}
const keys = Array.from(exceptions.keys()).sort((a, b) => a - b);
let r = '';
let last = 0;
keys.forEach((index) => {
const width = exceptions.get(index);
if (index > last) {
r += (index - last) === 1 ? `${defaultWidth} ` : `${repeat(index-last, defaultWidth)} `;
}
r += `${width} `;
last = index + 1;
});
if (isEdge) {
//fill up
r += `${repeat(total-last, defaultWidth)} `;
}
// remove last space
return r.slice(0, r.length - 1);
}
function exceptionsOf(columns, defaultWidth) {
const r = new Map();
columns.forEach((col, i) => {
if (col.width !== defaultWidth) {
r.set(i, col.width);
}
});
return r;
}
function createColumnRule(defaultWidth, columns) {
stylesheet.deleteRule(1);
const exceptions = exceptionsOf(columns, defaultWidth);
const names = columns.map((c) => c.id).join(' ');
let rule = `.lu > main > article > div, .lu > header > article { `;
if (!isEdge) {
rule += `
grid-template-columns: ${compute(exceptions, defaultWidth, columns.length)};
grid-template-areas: "${names}";
grid-auto-columns: ${defaultWidth};
}`;
} else {
rule += `
-ms-grid-columns: ${compute(exceptions, defaultWidth, columns.length)};
}`;
}
stylesheet.insertRule(rule, 1);
const l = stylesheet.cssRules.length;
for(let i = 2; i < l; ++i) {
stylesheet.deleteRule(2);
}
const frozen = columns.filter((c) => c.frozen);
if (frozen.length > 1 && !isEdge) {
//create the correct left offset
let offset = parseInt(frozen[0].width);
frozen.slice(1).forEach((c) => {
stylesheet.insertRule(`.lu > main > article > div > .frozen[data-id="${c.id}"], .lu > header > article .frozen[data-id="${c.id}"] {
left: ${offset}px;
}`, 2);
offset += parseInt(c.width);
});
}
}
function shiftFrozenColumns(columns, scrollLeft) {
if (!isEdge) {
return;
}
const l = stylesheet.cssRules.length;
for(let i = 2; i < l; ++i) {
stylesheet.deleteRule(2);
}
const hasFrozen = columns.some((c) => c.frozen);
if (hasFrozen) {
//create the correct left offset
let offset = 0;
let frozenWidth = 0;
columns.forEach((c) => {
if (c.frozen && offset < (scrollLeft + frozenWidth)) {
stylesheet.insertRule(`.lu > main > article > div > .frozen[data-id="${c.id}"], .lu > header > article .frozen[data-id="${c.id}"] {
transform: translate(${scrollLeft-offset+frozenWidth}px, 0);
}`, 2);
frozenWidth += parseInt(c.width);
}
offset += parseInt(c.width);
});
}
}
function computeTotal(rows, exceptions, rowHeight) {
if (exceptions.size === 0) {
return rows * rowHeight;
}
const e = Array.from(exceptions.values());
let total = (rows - e.length) * rowHeight;
e.forEach((c) => total += parseInt(c));
return total;
}
function computeFirstLast(offset, height, rowHeight, exceptions, total) {
const first = Math.max(0, Math.floor(offset / rowHeight));
const last = Math.min(total - 1, Math.ceil((offset + height) / rowHeight));
if (exceptions.size === 0) {
//uniform
return {first, last, firstRowPos: first * rowHeight};
}
const keys = Array.from(exceptions.keys()).sort((a, b) => a - b);
if (last < keys[0]) {
//before the first exception = uniform with no shift
return {first, last, firstRowPos: first * rowHeight};
}
//the position where the exceptions ends
const lastException = keys[keys.length - 1];
const end = keys.reduce((sum, k) => sum - rowHeight + exceptions.get(k), rowHeight * lastException);
if (offset > end) {
//uniform area after all exceptions
const firstAfter = Math.max(0, Math.floor((offset - end) / rowHeight));
const lastAfter = Math.min(total - 1, Math.ceil((offset - end + height) / rowHeight));
return {first: lastException + firstAfter, last: lastException + lastAfter, firstRowPos: all + firstAfter * rowHeight};
}
{
//we have some exceptions
let prev = 0, acc = 0;
const exceptionStarts = keys.map((index) => {
const height = parseInt(exceptions.get(index));
const between = (index - prev) * rowHeight;
prev = index;
const pos = acc + between;
acc = pos + height;
return {pos, index, rheight: height};
});
const visible = exceptionStarts.filter(({pos, rheight}) => (pos > offset && pos < (offset + height)) || ((pos + rheight) > offset && (pos + rheight) < (offset + height)));
console.log(offset, height, exceptionStarts, visible);
if (visible.length === 0) {
//we are in the between some exceptions and none are visible
const before = exceptionStarts.filter(({pos, rheight}) => (pos + rheight) < offset);
const closest = before[before.length - 1];
const shifted = offset - closest.pos - closest.rheight;
//uniform area after all exceptions
const firstAfter = Math.max(0, Math.floor(shifted / rowHeight));
const lastAfter = Math.min(total - 1, Math.ceil((shifted + height) / rowHeight));
return {first: closest.index + firstAfter, last: closest.index + lastAfter, firstRowPos: closest.pos + closest.rheight + firstAfter * rowHeight};
}
const firstException = visible[0];
const lastException = visible[visible.length - 1];
const rowsBefore = Math.max(0, Math.ceil((firstException.pos - offset) / rowHeight));
const rowsAfter = Math.max(0, Math.min(total - 1, Math.ceil((offset + height - lastException.pos - lastException.rheight) / rowHeight)));
//TODO buggy
return {first: firstException.index - rowsBefore, last: lastException.index + rowsAfter, firstRowPos: firstException.pos - rowsBefore * rowHeight};
}
}
stylesheet.insertRule(`.lu > main > article > div {
height: ${rowHeight}px;
}`, 0);
stylesheet.insertRule(`.lu > main > article > div, .lu > header > article {
/*column rule*/
}`, 1);
createColumnRule('100px', columns);
columns.forEach((c) => rankingHeader.appendChild(c.header()));
const total = computeTotal(rows, rowExceptions, rowHeight);
ranking.style.height=`${total.toFixed(0)}px`;
let visibleFirst = 0;
let visibleForcedFirst = 0;
let visibleFirstRowPos = 0;
let visibleLast = 0;
let visibleForcedLast = 0;
// pool of DOM Elements to use
const pool = [];
function removeAllRows(from) {
const arr = Array.from(ranking.children);
pool.push.apply(pool, arr);
ranking.innerHTML = '';
Array.from(ranking.children).forEach((item, i) => {
if (rowExceptions.has(i + from)) {
item.style.height = null;
}
});
}
function removeRows(from, to, fromBeginning) {
if (fromBeginning) {
for (let i = from; i <= to; ++i) {
const item = ranking.firstChild;
ranking.removeChild(item);
pool.push(item);
if (rowExceptions.has(i)) {
item.style.height = null;
}
}
} else {
for (let i = from; i <= to; ++i) {
const item = ranking.lastChild;
ranking.removeChild(item);
pool.push(item);
if (rowExceptions.has(i)) {
item.style.height = null;
}
}
}
}
function addRows(from, to, atBeginning) {
if (!atBeginning) {
for (let i = from; i <= to; ++i) {
let item;
if (pool.length > 0) {
item = pool.pop();
updateRow(item, i, columns);
} else {
item = createRow(i, columns);
}
if (rowExceptions.has(i)) {
item.style.height = rowExceptions.get(i);
}
ranking.appendChild(item);
}
} else {
for (let i = to; i >= from; --i) {
//insert at the beginning
let item;
if (pool.length > 0) {
item = pool.pop();
updateRow(item, i, columns);
} else {
item = createRow(i, columns);
}
if (rowExceptions.has(i)) {
item.style.height = rowExceptions.get(i);
}
ranking.insertAdjacentElement('afterbegin', item);
}
}
}
const prefetchRows = 20;
const cleanUpRows = 3;
let prefetchTimeout = -1;
function updateOffset(firstRowPos) {
visibleFirstRowPos = firstRowPos;
if (visibleFirst % 2 === 1) {
//odd start patch for correct background
ranking.classList.add('odd');
} else {
ranking.classList.remove('odd');
}
ranking.style.transform = `translate(0, ${firstRowPos.toFixed(0)}px)`;
ranking.style.height = `${(total - firstRowPos).toFixed(0)}px`;
}
function prefetchDown() {
prefetchTimeout = -1;
const nextLast = visibleLast + prefetchRows;
// add some rows in advance
if (visibleLast >= (visibleForcedLast + prefetchRows)) {
return;
}
addRows(visibleLast + 1, nextLast);
//console.log('prefetch', visibleFirst, visibleLast + 1, '=>', nextLast, ranking.children.length);
visibleLast = nextLast;
}
function prefetchUp() {
prefetchTimeout = -1;
if (visibleFirst <= (visibleForcedFirst - prefetchRows)) {
return;
}
const fakeOffset = Math.max(body.scrollTop - prefetchRows * rowHeight, 0);
const height = body.offsetHeight;
const {first, last, firstRowPos} = computeFirstLast(fakeOffset, height, rowHeight, rowExceptions, rows);
if (first === visibleFirst) {
return;
}
addRows(first, visibleFirst - 1, true);
//console.log('prefetch up ', visibleFirst, '=>', first, visibleLast, ranking.children.length);
visibleFirst = first;
updateOffset(firstRowPos);
}
function triggerPrefetch(isGoingDown) {
if (prefetchTimeout >= 0) {
clearTimeout(prefetchTimeout);
}
if ((isGoingDown && visibleLast >= (visibleForcedLast + prefetchRows)) || (!isGoingDown && visibleFirst <= (visibleForcedFirst - prefetchRows))) {
return;
}
prefetchTimeout = setTimeout(isGoingDown ? prefetchDown : prefetchUp, 20);
}
function cleanUpTop(first) {
prefetchTimeout = -1;
const newFirst = first - cleanUpRows;
if (newFirst <= visibleFirst) {
return;
}
removeRows(visibleFirst, newFirst - 1, true);
//console.log('cleanup up ', visibleFirst, '=>', newFirst, visibleLast, ranking.children.length);
let shift = (newFirst - visibleFirst) * rowHeight;
if (rowExceptions.size > 0) {
for(let i = visibleFirst; i < newFirst; ++i) {
if (rowExceptions.has(i)) {
shift += rowExceptions.get(i) - rowHeight;
}
}
}
visibleFirst = newFirst;
updateOffset(visibleFirstRowPos + shift);
prefetchDown();
}
function cleanUpBottom(last) {
prefetchTimeout = -1;
const newLast = last + cleanUpRows;
if (visibleLast <= newLast) {
return;
}
removeRows(newLast + 1, visibleLast);
//console.log('cleanup bottom', visibleFirst, visibleLast, '=>', newLast, ranking.children.length);
visibleLast = newLast;
prefetchUp();
}
function triggerCleanUp(first, last, isGoingDown) {
if (prefetchTimeout >= 0) {
clearTimeout(prefetchTimeout);
}
if ((isGoingDown && (first - cleanUpRows) <= visibleFirst) || (!isGoingDown && visibleLast <= (last + cleanUpRows))) {
return;
}
prefetchTimeout = setTimeout(isGoingDown ? cleanUpTop : cleanUpBottom, 20, isGoingDown ? first : last);
}
setTimeout(() => {
const {first, last, firstRowPos} = computeFirstLast(0, body.offsetHeight, rowHeight, rowExceptions, rows);
visibleFirst = visibleForcedFirst = first;
visibleLast = visibleForcedLast = last;
//console.log(first, last);
addRows(first, last);
updateOffset(firstRowPos);
}, 20);
function onScrolled(offset, height, isGoingDown, scrollLeft) {
const {first, last, firstRowPos} = computeFirstLast(offset, height, rowHeight, rowExceptions, rows);
if (isEdge) {
slopeGraph.style.transform = `translate(0, ${offset}px)`;
}
visibleForcedFirst = first;
visibleForcedLast = last;
if ((first - visibleFirst) >= 0 && (last - visibleLast) <= 0) {
triggerCleanUp(first, last, isGoingDown);
return; //nothing to do
}
if (first > visibleLast || last < visibleFirst) {
//no overlap, clean and draw everything
//console.log(`ff added: ${last - first + 1} removed: ${visibleLast - visibleFirst + 1} ${first}:${last} ${offset}`);
//removeRows(visibleFirst, visibleLast);
removeAllRows(visibleFirst);
addRows(first, last);
} else if (first < visibleFirst) {
//some first rows missing and some last rows to much
//console.log(`up added: ${visibleFirst - first + 1} removed: ${visibleLast - last + 1} ${first}:${last} ${offset}`);
removeRows(last + 1, visibleLast);
addRows(first, visibleFirst - 1, true);
} else {
//console.log(`do added: ${last - visibleLast + 1} removed: ${first - visibleFirst + 1} ${first}:${last} ${offset}`);
//some last rows missing and some first rows to much
removeRows(visibleFirst, first - 1, true);
addRows(visibleLast + 1, last);
}
visibleFirst = first;
visibleLast = last;
updateOffset(firstRowPos);
triggerPrefetch(isGoingDown);
}
.lu {
position: relative;
display: flex;
flex-direction: column;
align-items: stretch;
align-content: stretch;
max-height: 40vh;
max-width: 100vw;
> header {
flex: 0 0 auto;
overflow-x: hidden;
font-weight: bold;
border-bottom: 1px solid black;
}
> main {
flex: 1 1 auto;
overflow-y: auto;
> article {
display: flex;
flex-direction: column;
align-items: stretch;
align-content: stretch;
&:not(.odd) > div:nth-child(even) > *,
&.odd > div:nth-child(odd) > * {
background: lightgray;
}
}
> .slopegraph {
background-color: steelblue;
}
}
> header,
> main {
display: flex;
flex-direction: row;
align-items: stretch;
justify-content: stretch;
> article {
flex: 0 0 auto;
}
> .slopegraph {
flex: 0 0 auto;
width: 200px;
min-height: 1px;
}
}
&:not(.ms-edge) > main > .slopegraph {
position: sticky;
top: 0;
}
> header > article,
> main > article > div {
display: -ms-grid;
display: grid;
grid-auto-flow: column;
-ms-grid-column-align: stretch;
-ms-grid-row-align: stretch;
align-items: stretch;
justify-items: stretch;
> * {
background: white;
}
.bar {
width: 50%;
background-color: blue;
}
}
&:not(.ms-edge) > header > article,
&:not(.ms-edge) > main > article > div {
> .frozen { //frozen one
position: sticky;
left: 0;
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment