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';
}
}