wpis

Duża ilość danych w tabelach? To żaden problem!

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.