import { ChangeDetectionStrategy, Component, computed, effect, inject, input, model, signal, viewChild, } from '@angular/core'; import { Skeleton } from 'primeng/skeleton'; import { Table, TableLazyLoadEvent, TableModule } from 'primeng/table'; import { Aggregate, AggregateType, Column, DuckdbService, Filter, FilterValue, aggregateTypes, } from '../duckdb.service'; import { ButtonModule } from 'primeng/button'; import { IconField } from 'primeng/iconfield'; import { InputIconModule } from 'primeng/inputicon'; import { InputTextModule } from 'primeng/inputtext'; import { Menu } from 'primeng/menu'; import { MenuItem } from 'primeng/api'; import { DecimalPipe } from '@angular/common'; import { PaginatorModule, PaginatorState } from 'primeng/paginator'; @Component({ selector: 'app-file-viewer', standalone: true, imports: [ TableModule, Skeleton, ButtonModule, IconField, InputIconModule, InputTextModule, Menu, DecimalPipe, PaginatorModule, ], template: ` @if (file() && enabledColumns().length > 0) {
@for (col of columns; track $index) { {{ col.name }} } @for (col of columns; track $index) { {{ rowData[col.name] }} } @for (col of columns; track $index) { } @if (hasAggregates()) { @for (col of enabledColumns(); track $index) { @if (col.type === 'DOUBLE' || col.type === 'BIGINT') {
mediation {{ aggregateValues()[$index] | number }}
} }
}
@if (currentRowCount() > PAGE_SIZE) { } } `, styleUrl: './file-viewer.component.css', changeDetection: ChangeDetectionStrategy.OnPush, }) export class FileViewerComponent { file = input(); private duckdbService = inject(DuckdbService); // Can't be computed since effect has to run first so file exists in duckdb columns = model([]); protected aggregates = signal<(Aggregate | undefined)[]>([]); protected currentAggregateColumn = signal(undefined); protected aggregateValues = signal<(number | undefined)[]>([]); 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; protected currentValue = signal([]); protected appliedFilters = signal([]); protected currentRowCount = signal(0n); private table = viewChild(Table); protected hasAggregates = computed( () => !!this.columns().find((col) => this.isAggregateColumn(col)), ); protected enabledColumns = computed(() => this.columns().filter((col) => col.enabled), ); protected aggregateItems: MenuItem[] = aggregateTypes.map((type) => ({ label: type, command: () => this.updateAggregateValue(type), })); constructor() { effect(async () => { await this.loadEmpty(); }); } protected clear(table: Table) { table.clear(); table.multiSortMeta = [{ field: this.columns()[0].name, order: 1 }]; table.sortMultiple(); } protected async onLazyLoad(event: TableLazyLoadEvent) { const file = this.file(); if (file) { this.appliedFilters.set( event.filters ? Object.entries(event.filters).flatMap(([column, filter]) => { if (Array.isArray(filter)) { const firstFilter = filter[0]; return Filter.parse({ column, value: filter .filter((f) => f.value != null) .map((f) => FilterValue.parse({ value: f.value, matchType: f.matchMode, }), ), operator: firstFilter.operator, }); } else if (filter) { return Filter.parse({ column, value: [ FilterValue.parse({ value: filter.value, matchType: filter.matchMode, }), ], operator: filter.operator, }); } return []; }) : [], ); const rows = await this.duckdbService.getRows( file, (event.first ?? 0) + this.currentPage() * this.PAGE_SIZE, event.rows ?? 0, this.columns(), event.multiSortMeta?.map((meta) => ({ name: meta.field, sortType: meta.order < 0 ? 'desc' : 'asc', })) ?? [], this.appliedFilters(), this.aggregates().filter((agg) => !!agg) as Aggregate[], ); // 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 for (let i = 0; i < this.currentValue().length; i++) { if ( i < event.first! - this.NUM_ROWS || i > event.first! + event.rows! + this.NUM_ROWS ) { this.currentValue()[i] = undefined; } } // Can't update the current value, otherwise we get an infinite loop due to primeng change detection rerunning (plus it's faster to mutate) for (let i = 0; i < rows.rows!.length; i++) { this.currentValue()[event.first! + i] = rows.rows![i]; } event.forceUpdate!(); } } private async loadEmpty() { const file = this.file(); if (file) { await this.duckdbService.addFile(file); this.columns.set(await this.duckdbService.getColumns(file)); this.aggregates.set(new Array(this.columns().length)); const rows = await this.duckdbService.getRows( file, 0, this.NUM_ROWS, this.columns(), [], [], [], ); 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); } // TODO: Replace this with previous state once persistence is implemented (should be per tab at the very least) const table = this.table(); if (table) { this.clear(table); table.multiSortMeta = [{ field: this.columns()[0].name, order: 1 }]; table.sortMultiple(); } } private async updateAggregateValue(type: AggregateType) { this.aggregates.update((aggregates) => { const copy = aggregates.slice(); copy[this.currentAggregateColumn()!] = { type, column: this.columns()[this.currentAggregateColumn()!].name, }; return copy; }); const aggregateValue = await this.duckdbService.getAggregateValue( this.file()!, this.appliedFilters(), this.aggregates()[this.currentAggregateColumn()!]!, ); this.aggregateValues.update((values) => { const copy = values.slice(); copy[this.currentAggregateColumn()!] = aggregateValue; 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, }), ); } protected isAggregateColumn(col: Column) { return col.type === 'DOUBLE' || col.type === 'BIGINT'; } }