
Większość nowych projektów które rozpoczynamy z klientem w BrandOriented, uświadamia, że Excel funkcjonuje powszechnie w organizacjach, jako narzędzie do wszystkiego, w tym min. baza danych, harmonogram, system analityczny i statystyczny, a nawet monitoring procesów. Świadczy to nie tylko o powszechności aplikacji, ale również o przywiązaniu się użytkowników do tego rozwiązania pomimo wątpliwej często skuteczności niż dedykowane narzędzia. Niezależnie od wdrożonych systemów klasy ERP np. ORACLE, SAP 4Hana, Excel króluje niezmiennie, a użytkownicy “kręcą” manualnie raporty zestawiając dane, formatując, czasem nawet czyszcząc struktury danych z różnych lat, aby móc przygotować zestawienie. Dzieje się to z różną skutecznością, ale jako jedną z podstawowych zalet, wskazywana przez użytkowników, jest możliwość wyświetlania dużych ilości danych w jednym widoku. System Effiana, który ma za zadanie usprawniać codzienną pracę tych osób, nie może oczywiście ustępować pod tym względem.
W tym miejscu zaczyna się prawdziwe wyzwanie, jak doprowadzić do sytuacji, gdzie użytkownik pracuje na gigantycznych tabelach, często mających po kilkaset kolumn i kilkanaście tysięcy wierszy i nie musi przejmować się "skakaniem" aplikacji czy ciągłym ładowaniem kolejnych danych. Aby doprowadzić do takiej sytuacji, zespół BrandOriented pracujący nad systemem Effiana, przeanalizował wiele możliwości i rozwiązań. Niestety każde miało jakieś ograniczenia, mniejsze bądź większe. Dlatego postanowiliśmy z każdego z nich wziąć po kawałku i zaprojektować kompleksowe rozwiązanie, które zapewniło wydajność i stabilność.
Nie wchodząc w zbytnie szczegóły na wstępie, przejdźmy do konkretów, a za przykład niech posłuży tabela składająca się z 1 000 kolumn oraz 60 000 rekordów.
Chcąc wyświetlić całą tabelę w tradycyjny sposób, otrzymalibyśmy błąd „Kurza twarz" (w Chrome) lub -- w najlepszym przypadku -- duże problemy z wydajnością podczas przewijania.
Jakie więc mamy możliwości, aby rozwiązać problemy związane z wydajnością?
Z pomocą przychodzi nam Canvas, czyli upraszczając „krzywe". Rysując w Canvas, odciążamy procesor i przenosimy operacje związane z renderowaniem na kartę graficzną. Oczywiście jak wszystko, nawet Canvas ma limity wydajnościowe, dlatego, aby sprostać tak dużym ilością danych, należy dodać ograniczenie zakresu renderowania elementów w viewporcie. Dodatkowo nasze dane wejściowe (kolumny, wiersze) musimy podzielić na mniejsze zbiory.
Z teorii przejdźmy zatem do praktyki.
Założenia
- tabela budowana jest z dwóch zbiorów: kolumn (layout) oraz wierszy/rekordów (obiekty)
- podczas ładowania danych wykonywane są obliczenia dla całej tabeli (kolumny, wiersze)
- w czasie rzeczywistym, podczas przewijania, obliczane są współrzędne komórek.
Uwaga! Przykłady zawierają uproszczony kod (którego celem jest zobrazowanie mechanizmu rozwiązania, a nie gotowego kodu aplikacji) bazujący na obliczeniach dla kolumn.
Wykorzystane funkcje:
function sumArrayValues(items, itemGetter = (value) => value) { 
    let sum = 0; 
    for (let i = 0; i < items.length; i += 1) { 
        sum += itemGetter(items[i]); 
    }
    return sum; 
}
            Przygotowanie struktury i wymiarów
Na samym początku musimy przygotować odpowiednią strukturę danych, która w tym wypadku została maksymalnie uproszczona, aby nie odwracać uwagi od rozwiązania problemu nad którym się skupiamy.
Przykładowa struktura:
# Kolumny: 
const columns = [ 
    { 
        name: 'columnName1', 
        label: 'Kolumna 1', 
        width: 120 
    }, 
    { 
        name: 'columnName2', 
        label: 'Kolumna 2', 
        width: 120 
    }, 
    ... 
    { 
        name: 'columnName3', 
        label: 'Kolumna 3', 
        width: 120 
    }, 
];
# Pojedynczy wiersz: 
const rows = [ 
    { 
        columnName1: 'value 1', 
        columnName1: 'value 2', 
        columnName1: 'value 3' 
    } 
];
            Obliczanie wielkości tabeli
Dla uproszczenia przyjmijmy, że każdy wiersz ma stałą wysokość 40px. Docelowo wiersze mogą mieć różne wysokości co pozwoli na dostosowywanie ich do treści zawartych w komórkach. Można pokusić się o dodanie mechanizmu zarówno dynamicznego dostosowywania wysokości jak i ręcznego, co mimo wszystko w minimalnym stopniu wpłynie na wydajność rozwiązania.
const dimentions = { 
    contentHeight: 0, 
    contentWidth: 0
};
dimentions.contentWidth = sumArrayValues(columns, (item) => item.width);
dimentions.contentHeight = sumArrayValues(columns, (item) => 40);
            Podział struktury na mniejsze fragmenty:
W celu przyśpieszenia iterowania po kolumnach/wierszach należy podzielić zbiory na mniejsze fragmenty.
Struktura chunków dla kolumn:
const columnChunks = { 
    columnChunks: [], 
    indexChunks: [], 
    offsetChunks: [0], 
    widthChunks: [] 
}; 
let counter = 0; 
let indexCounter = 0; 
while (columns.length > 0) { 
    const itemsChunk = columns.splice(0, 100); 
    const offset = sumArrayValues(itemsChunk, (item) => item.width); 
    const offsetPrevious = columnChunks.offsetChunks[counter] || 0; 
    const chunkIndexes = itemsChunk.map((item, index) => indexCounter + index); 
    columnChunks.widthChunks.push(itemsChunk); 
    columnChunks.indexChunks.push(chunkIndexes); 
    columnChunks.columnChunks.push(itemsChunk); 
    columnChunks.offsetChunks.push(offset + offsetPrevious); 
    indexCounter += itemsChunk.length; 
    counter += 1; 
}
            Tabela
Mając przygotowane fragmenty tabeli, możemy zająć się obliczeniami i zaprogramować mechanizm, który wyświetli tylko tą cześć tabeli, która ma być widoczna w viewporcie na podstawie aktualnej pozycji paska przewijania. To jest właściwie najważniejszy element, to w tym miejscu następuje optymalizacja.
Widoczna część tabeli
const visibleRange = { 
    columnIndexList: [], 
    columnList: [], 
    columnOffsetList: [], 
    columnWidthList: [] 
};
            Przyjmijmy, że scrollState.left zawiera aktualną pozycję paska przewijania.
for (let chunkIndex = 0; chunkIndex < columnsChunks.offsetChunks.length; chunkIndex += 1) { 
    const chunkOffset = columnsChunks.offsetChunks[chunkIndex]; 
    for (let colIndex = 0; colIndex <= columnsChunks.widthChunks[chunkIndex]?.length; colIndex += 1) { 
        const horizontalPosition = sumArrayValues(columnsChunks.widthChunks[chunkIndex].slice(0, colIndex) || []); 
        const horizontalOffset = chunkOffset + horizontalPosition; 
        const colIndexMapped = columnsChunks.indexChunks[chunkIndex][colIndex]; 
        
        if (colIndexMapped <= frozenColumnsAmount || (scrollState.left - toleranceMargin <= horizontalOffset && scrollState.left + dimensions.viewportWidth + toleranceMargin >= horizontalOffset)) { 
          visibleRange.columnIndexList.push(colIndexMapped); 
          visibleRange.columnWidthList.push(columnsChunks.widthChunks[chunkIndex][colIndex]?.width); 
          visibleRange.columnList.push(columnsChunks.columnChunks[chunkIndex][colIndex]); 
          visibleRange.columnOffsetList.push(horizontalOffset); 
        }
    }
}
            Obliczanie pozycji oraz rozmiarów komórek
Na podstawie wartości 'visibleRange' możemy wyliczyć współrzędne dla widocznych komórek.
const cells = {}; 
for (let rowIndex = 0; rowIndex < visibleRange.rowHeightList.length; rowIndex += 1) { 
    const rowPosition = visibleRange.rowOffsetList[rowIndex]; 
    const rowIndexMapped = visibleRange.rowIndexList[rowIndex]; 
    for (let colIndex = 0; colIndex < visibleRange.columnWidthList.length; colIndex += 1) { 
        const columnPosition = visibleRange.columnOffsetList[colIndex]; 
        const colIndexMapped = visibleRange.columnIndexList[colIndex]; 
        
        cells[colIndexMapped:rowIndexMapped] = [ 
            columnPosition, 
            rowPosition, 
            visibleRange.columnWidthList[colIndex], 
            visibleRange.rowHeightList[rowIndex], 
            columns[colIndexMapped]?.name 
        ]; 
    } 
}
            Renderowanie
Bazując na wcześniejszych wyliczeniach w 'visibleRange', generujemy pionowe/poziome linie oraz nakładamy tekst w komórkach.
const tableGridCanvas = document.querySelctor('#table').getContext('2d'); 
const TABLE_RENDER_OFFSET = 0.5; 
const offsetX = -scrollState.left; 
const offsetY = -scrollState.top; 
const height = dimensions.viewportHeight; 
const width = dimensions.viewportWidth; 
// kolumny - pionowe linie 
for (let colIndex = 0; colIndex <= visibleRange.columnWidthList.length; colIndex += 1) { 
    const horizontalOffset = visibleRange.columnOffsetList[colIndex]; 
    tableGridCanvas.beginPath(); 
    tableGridCanvas.lineWidth = 1; 
    tableGridCanvas.strokeStyle = '#000'; 
    tableGridCanvas.moveTo(horizontalOffset + TABLE_RENDER_OFFSET + offsetX, TABLE_RENDER_OFFSET + offsetY); 
    tableGridCanvas.lineTo(horizontalOffset + TABLE_RENDER_OFFSET + offsetX, height + TABLE_RENDER_OFFSET + offsetY); 
    tableGridCanvas.stroke(); 
    tableGridCanvas.closePath(); 
} 
// wiersze - poziome linie 
for (let rowIndex = 0; rowIndex <= visibleRange.rowHeightList.length; rowIndex += 1) { 
    const verticalOffset = visibleRange.rowOffsetList[rowIndex]; 
    tableGridCanvas.beginPath(); 
    tableGridCanvas.lineWidth = 1; 
    tableGridCanvas.strokeStyle = '#000'; 
    tableGridCanvas.moveTo(TABLE_RENDER_OFFSET, verticalOffset + TABLE_RENDER_OFFSET + offsetY); 
    tableGridCanvas.lineTo(width + TABLE_RENDER_OFFSET, verticalOffset + TABLE_RENDER_OFFSET + offsetY); 
    tableGridCanvas.stroke(); 
    tableGridCanvas.closePath(); 
} 
// treść 
for (let rowIndex = 0; rowIndex < visibleRange.rowHeightList.length; rowIndex += 1) { 
    for (let colIndex = 0; colIndex < visibleRange.columnWidthList.length; colIndex += 1) { 
        const colIndexMapped = visibleRange.columnIndexList[colIndex]; 
        const rowIndexMapped = visibleRange.rowIndexList[rowIndex]; 
        const [x, y, width, height] = cells[colIndexMapped:rowIndexMapped] || []; 
        const columnName = columns[colIndex].name; 
        const content = (rows[rowIndex] && rows[rowIndex][columnName]?.toString()) || ''; 
        const paddingHorizontal = 5; 
        const paddingVertical = 1; 
        const positionVertical = paddingVertical + height / 2; 
        tableGridCanvas.beginPath(); 
        tableGridCanvas.font = '13px Arial'; 
        tableGridCanvas.fillStyle = '#000'; 
        tableGridCanvas.textBaseline = 'middle'; 
        tableGridCanvas.fillText( 
            content, 
            x + TABLE_RENDER_OFFSET + offsetX + positionHorizontal, 
            y + TABLE_RENDER_OFFSET + offsetY + positionVertical, 
            width 
        ); 
        tableGridCanvas.closePath(); 
    } 
}
            Eventy
W celu wykrycia np. kliknięcia w komórkę podpinamy event "click" dla całego obiektu Canvas.
let currentCell = { 
    dimensions: [0, 0, 0, 0], 
    id: '0:0' 
}; 
tableGridCanvas.addEventListener('click', (event) => { 
    const keyList = Object.keys(cells); 
    const valueList = Object.values(cells); 
    for (let j = 0; j < valueList.length; j += 1) { 
        const current = [...cells[j]]; 
        const [x, y, width, height] = current; 
        
        let cursorX = event.pageX; 
        let cursorY = event.pageY; 
        if (x <= cursorX && cursorX <= x + width - 1 && y <= cursorY && cursorY <= y + height - 1) { 
            currentCell = { 
                dimensions: current, 
                id: keyList[j] 
            }); 
        } 
    } 
});
            Podsumowanie
Canvas wraz z renderowaniem treści ograniczonym do wielkości viewportu oraz podział danych na mniejsze zbiory, sprawia, że operowanie na dużej ilości elementów jest szybkie i wydajne. Dodatkowo wyciągnięcie logiki komórek poza canvas pozwala zmniejszyć obciążenie w trakcie generowania tabeli, a co za tym idzie znacznie przyspieszyć działania całej aplikacji.
W efekcie możemy zwiększyć ilość danych prezentowanych w aplikacji bez zbytniego obciążania przeglądarki użytkownika.
Pierwotnie opublikowano na LinkedIn.

