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.