Switch to zoneless change detection, add paging for large datasets
Some checks failed
build / build (push) Failing after 2m20s

This commit is contained in:
2025-05-31 15:32:36 +09:30
parent e40978e434
commit 306df80b74
3 changed files with 154 additions and 118 deletions

View File

@@ -1,7 +1,6 @@
import { import {
ApplicationConfig, ApplicationConfig,
provideAppInitializer, provideZonelessChangeDetection,
provideZoneChangeDetection,
} from '@angular/core'; } from '@angular/core';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
@@ -12,7 +11,7 @@ import { routes } from './app.routes';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideZoneChangeDetection({ eventCoalescing: true }), provideZonelessChangeDetection(),
provideRouter(routes), provideRouter(routes),
provideAnimationsAsync(), provideAnimationsAsync(),
providePrimeNG({ providePrimeNG({

View File

@@ -1,4 +1,5 @@
:host { :host {
display: block; display: flex;
flex-direction: column;
height: 100%; height: 100%;
} }

View File

@@ -1,5 +1,6 @@
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef,
Component, Component,
effect, effect,
inject, inject,
@@ -30,6 +31,7 @@ import { InputTextModule } from 'primeng/inputtext';
import { Menu } from 'primeng/menu'; import { Menu } from 'primeng/menu';
import { MenuItem } from 'primeng/api'; import { MenuItem } from 'primeng/api';
import { DecimalPipe } from '@angular/common'; import { DecimalPipe } from '@angular/common';
import { PaginatorModule, PaginatorState } from 'primeng/paginator';
@Component({ @Component({
selector: 'app-file-viewer', selector: 'app-file-viewer',
@@ -43,107 +45,123 @@ import { DecimalPipe } from '@angular/common';
InputTextModule, InputTextModule,
Menu, Menu,
DecimalPipe, DecimalPipe,
PaginatorModule,
], ],
template: ` template: `
@if (file() && columns().length > 0) { @if (file() && columns().length > 0) {
<p-table <div class="h-full">
#table <p-table
sortMode="multiple" #table
[resizableColumns]="true" sortMode="multiple"
columnResizeMode="expand" [resizableColumns]="true"
[columns]="columns()" columnResizeMode="expand"
[value]="currentValue()" [columns]="columns()"
showGridlines [value]="currentValue()"
[scrollable]="true" showGridlines
scrollHeight="flex" [scrollable]="true"
[rows]="numRows" scrollHeight="flex"
[virtualScroll]="true" [rows]="NUM_ROWS"
[virtualScrollItemSize]="46" [virtualScroll]="true"
[lazy]="true" [virtualScrollItemSize]="46"
(onLazyLoad)="onLazyLoad($event)" [lazy]="true"
[multiSortMeta]="[{ field: columns()[0].name, order: 1 }]" (onLazyLoad)="onLazyLoad($event)"
stripedRows [multiSortMeta]="[{ field: columns()[0].name, order: 1 }]"
> stripedRows
<ng-template #caption> >
<div class="flex"> <ng-template #caption>
<p-button <div class="flex">
label="Reset" <p-button
[outlined]="true" label="Reset"
icon="pi pi-filter-slash" [outlined]="true"
(click)="clear(table)" icon="pi pi-filter-slash"
/> (click)="clear(table)"
<p-iconfield iconPosition="left" class="ml-auto"> />
<p-inputicon> <p-iconfield iconPosition="left" class="ml-auto">
<i class="pi pi-search"></i> <p-inputicon>
</p-inputicon> <i class="pi pi-search"></i>
<input pInputText type="text" placeholder="Search keyword" /> </p-inputicon>
</p-iconfield> <input pInputText type="text" placeholder="Search keyword" />
</div> </p-iconfield>
</ng-template> </div>
<ng-template #header let-columns> </ng-template>
<tr> <ng-template #header let-columns>
@for (col of columns; track $index) { <tr>
<th @for (col of columns; track $index) {
pReorderableColumn <th
pResizableColumn pReorderableColumn
[pSortableColumn]="col.name" pResizableColumn
class="w-3xs min-w-3xs max-w-3xs" [pSortableColumn]="col.name"
> class="w-3xs min-w-3xs max-w-3xs"
{{ col.name }} >
<p-columnFilter type="text" [field]="col.name" display="menu" /> {{ col.name }}
<p-sortIcon [field]="col.name" /> <p-columnFilter
<!-- More options can go here... integrate it with the column filter? --> type="text"
</th> [field]="col.name"
} display="menu"
</tr> />
</ng-template> <p-sortIcon [field]="col.name" />
<ng-template #body let-rowData let-columns="columns"> <!-- More options can go here... integrate it with the column filter? -->
<tr style="height: 46px"> </th>
@for (col of columns; track $index) { }
<td pReorderableRowHandle class="w-3xs min-w-3xs max-w-3xs"> </tr>
{{ rowData[col.name] }} </ng-template>
</td> <ng-template #body let-rowData let-columns="columns">
} <tr style="height: 46px">
</tr> @for (col of columns; track $index) {
</ng-template> <td pReorderableRowHandle class="w-3xs min-w-3xs max-w-3xs">
<ng-template #loadingbody let-columns="columns"> {{ rowData[col.name] }}
<tr style="height: 46px"> </td>
@for (col of columns; track $index) { }
<td class="w-3xs min-w-3xs max-w-3xs"> </tr>
<p-skeleton /> </ng-template>
</td> <ng-template #loadingbody let-columns="columns">
} <tr style="height: 46px">
</tr> @for (col of columns; track $index) {
</ng-template> <td class="w-3xs min-w-3xs max-w-3xs">
<ng-template #footer> <p-skeleton />
<tr> </td>
@for (col of columns(); track $index) { }
<td #attachment> </tr>
@if (col.type === 'DOUBLE') { </ng-template>
<div class="flex items-baseline"> <ng-template #footer>
<p-button <tr>
(click)=" @for (col of columns(); track $index) {
currentAggregateColumn.set($index); <td #attachment>
aggregateMenu.toggle($event) @if (col.type === 'DOUBLE' || col.type === 'BIGINT') {
" <div class="flex items-baseline">
[label]="aggregates()[$index]?.type?.toUpperCase()" <p-button
> (click)="
<ng-template #icon> currentAggregateColumn.set($index);
<span class="material-symbols-outlined"> aggregateMenu.toggle($event)
mediation "
</span> [label]="aggregates()[$index]?.type?.toUpperCase()"
</ng-template> >
</p-button> <ng-template #icon>
<span class="flex-1 text-end"> <span class="material-symbols-outlined">
{{ aggregateValues()[$index] | number }} mediation
</span> </span>
</div> </ng-template>
} </p-button>
</td> <span class="flex-1 text-end">
} {{ aggregateValues()[$index] | number }}
</tr> </span>
</ng-template> </div>
</p-table> }
</td>
}
</tr>
</ng-template>
</p-table>
</div>
@if (currentRowCount() > PAGE_SIZE) {
<!-- Can't use default table paginator as it doesn't work with lazy, it ends up loading all values rather than just the standard 200 -->
<p-paginator
(onPageChange)="onPageChange($event)"
[first]="currentPage() * PAGE_SIZE"
[rows]="PAGE_SIZE"
[totalRecords]="currentRowCount()"
/>
}
<p-menu #aggregateMenu [model]="aggregateItems" [popup]="true" /> <p-menu #aggregateMenu [model]="aggregateItems" [popup]="true" />
} }
`, `,
@@ -160,14 +178,16 @@ export class FileViewerComponent {
protected aggregates = signal<(Aggregate | undefined)[]>([]); protected aggregates = signal<(Aggregate | undefined)[]>([]);
protected currentAggregateColumn = signal<number | undefined>(undefined); protected currentAggregateColumn = signal<number | undefined>(undefined);
protected aggregateValues = signal<(number | undefined)[]>([]); protected aggregateValues = signal<(number | undefined)[]>([]);
protected numRows = 200; protected readonly NUM_ROWS = 200;
protected currentPage = signal(0);
// This is required since once the number of rows exceeds ~300000, the footer gets blocked
// and the user can't even scroll to those bottom rows.
protected readonly PAGE_SIZE = 300_000;
// TODO: This needs to be limited to 500000 or something, since exceeding the max safe integer
// will cause weird layout and prevent the bottom rows/footer from being viewed. Alternately
// we can use paging instead, but allow paging to be virtual with a max page size
// of 500000
protected currentValue = signal<any[]>([]); protected currentValue = signal<any[]>([]);
protected appliedFilters = signal<Filter[]>([]); protected appliedFilters = signal<Filter[]>([]);
protected currentRowCount = signal(0n);
private table = viewChild(Table); private table = viewChild(Table);
@@ -226,7 +246,7 @@ export class FileViewerComponent {
); );
const rows = await this.duckdbService.getRows( const rows = await this.duckdbService.getRows(
file, file,
event.first ?? 0, (event.first ?? 0) + this.currentPage() * this.PAGE_SIZE,
event.rows ?? 0, event.rows ?? 0,
this.columns(), this.columns(),
event.multiSortMeta?.map((meta) => ({ event.multiSortMeta?.map((meta) => ({
@@ -236,23 +256,21 @@ export class FileViewerComponent {
this.appliedFilters(), this.appliedFilters(),
this.aggregates().filter((agg) => !!agg) as Aggregate[], this.aggregates().filter((agg) => !!agg) as Aggregate[],
); );
if (this.currentValue().length !== Number(rows.totalRows)) {
this.currentValue.set(Array.from({ length: Number(rows.totalRows) }));
return;
}
// First clear out existing data, don't want to risk loading entire file into memory // First clear out existing data, don't want to risk loading entire file into memory
// Keep data in previous/next page so when user scrolls between 2 pages they don't see missing data // Keep data in previous/next page so when user scrolls between 2 pages they don't see missing data
for (let i = 0; i < this.currentValue().length; i++) { for (let i = 0; i < this.currentValue().length; i++) {
if ( if (
i < event.first! - this.numRows || i < event.first! - this.NUM_ROWS ||
i > event.first! + event.rows! + this.numRows i > event.first! + event.rows! + this.NUM_ROWS
) { ) {
this.currentValue()[i] = undefined; this.currentValue()[i] = undefined;
} }
} }
// Can't update the current value, otherwise we get an infinite loop // Can't update the current value, otherwise we get an infinite loop due to primeng change detection rerunning (plus it's faster to mutate)
this.currentValue().splice(event.first!, event.rows!, ...rows.rows); for (let i = 0; i < event.rows!; i++) {
this.currentValue()[event.first! + i] = rows.rows![i];
}
event.forceUpdate!(); event.forceUpdate!();
} }
} }
@@ -266,13 +284,19 @@ export class FileViewerComponent {
const rows = await this.duckdbService.getRows( const rows = await this.duckdbService.getRows(
file, file,
0, 0,
this.numRows, this.NUM_ROWS,
this.columns(), this.columns(),
[], [],
[], [],
[], [],
); );
const newValue = Array.from({ length: Number(rows.totalRows) }); this.currentRowCount.set(rows.totalRows);
this.currentPage.set(0);
const newValue = Array.from({
length: Number(
rows.totalRows > this.PAGE_SIZE ? this.PAGE_SIZE : rows.totalRows,
),
});
this.currentValue.set(newValue); this.currentValue.set(newValue);
} }
// TODO: Replace this with previous state once persistence is implemented // TODO: Replace this with previous state once persistence is implemented
@@ -304,4 +328,16 @@ export class FileViewerComponent {
return copy; return copy;
}); });
} }
protected onPageChange(event: PaginatorState) {
this.currentPage.set(event.page!);
this.currentValue.set(
Array.from({
length:
event.page! === event.pageCount! - 1
? Number(this.currentRowCount()) % this.PAGE_SIZE
: this.PAGE_SIZE,
}),
);
}
} }