358 lines
11 KiB
TypeScript
358 lines
11 KiB
TypeScript
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) {
|
|
<div class="h-full">
|
|
<p-table
|
|
#table
|
|
sortMode="multiple"
|
|
[resizableColumns]="true"
|
|
columnResizeMode="expand"
|
|
[columns]="enabledColumns()"
|
|
[value]="currentValue()"
|
|
showGridlines
|
|
[scrollable]="true"
|
|
scrollHeight="flex"
|
|
[rows]="NUM_ROWS"
|
|
[virtualScroll]="true"
|
|
[virtualScrollItemSize]="46"
|
|
[lazy]="true"
|
|
(onLazyLoad)="onLazyLoad($event)"
|
|
[multiSortMeta]="[{ field: columns()[0].name, order: 1 }]"
|
|
stripedRows
|
|
>
|
|
<ng-template #caption>
|
|
<div class="flex">
|
|
<p-button
|
|
label="Reset"
|
|
[outlined]="true"
|
|
icon="pi pi-filter-slash"
|
|
(click)="clear(table)"
|
|
/>
|
|
<p-iconfield iconPosition="left" class="ml-auto">
|
|
<p-inputicon>
|
|
<i class="pi pi-search"></i>
|
|
</p-inputicon>
|
|
<input pInputText type="text" placeholder="Search keyword" />
|
|
</p-iconfield>
|
|
</div>
|
|
</ng-template>
|
|
<ng-template #header let-columns>
|
|
<tr>
|
|
@for (col of columns; track $index) {
|
|
<th
|
|
pReorderableColumn
|
|
pResizableColumn
|
|
[pSortableColumn]="col.name"
|
|
class="w-3xs min-w-3xs max-w-3xs"
|
|
>
|
|
{{ col.name }}
|
|
<p-columnFilter
|
|
type="text"
|
|
[field]="col.name"
|
|
display="menu"
|
|
/>
|
|
<p-sortIcon [field]="col.name" />
|
|
<!-- More options can go here... integrate it with the column filter? -->
|
|
</th>
|
|
}
|
|
</tr>
|
|
</ng-template>
|
|
<ng-template #body let-rowData let-columns="columns">
|
|
<tr style="height: 46px">
|
|
@for (col of columns; track $index) {
|
|
<td
|
|
pReorderableRowHandle
|
|
class="w-3xs min-w-3xs max-w-3xs text-ellipsis whitespace-nowrap"
|
|
>
|
|
{{ rowData[col.name] }}
|
|
</td>
|
|
}
|
|
</tr>
|
|
</ng-template>
|
|
<ng-template #loadingbody let-columns="columns">
|
|
<tr style="height: 46px">
|
|
@for (col of columns; track $index) {
|
|
<td class="w-3xs min-w-3xs max-w-3xs">
|
|
<p-skeleton />
|
|
</td>
|
|
}
|
|
</tr>
|
|
</ng-template>
|
|
@if (hasAggregates()) {
|
|
<ng-template #footer>
|
|
<tr>
|
|
@for (col of enabledColumns(); track $index) {
|
|
<td #attachment>
|
|
@if (col.type === 'DOUBLE' || col.type === 'BIGINT') {
|
|
<div class="flex items-baseline">
|
|
<p-button
|
|
(click)="
|
|
currentAggregateColumn.set($index);
|
|
aggregateMenu.toggle($event)
|
|
"
|
|
[label]="aggregates()[$index]?.type?.toUpperCase()"
|
|
>
|
|
<ng-template #icon>
|
|
<span class="material-symbols-outlined">
|
|
mediation
|
|
</span>
|
|
</ng-template>
|
|
</p-button>
|
|
<span class="flex-1 text-end">
|
|
{{ aggregateValues()[$index] | number }}
|
|
</span>
|
|
</div>
|
|
}
|
|
</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" />
|
|
}
|
|
`,
|
|
styleUrl: './file-viewer.component.css',
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
})
|
|
export class FileViewerComponent {
|
|
file = input<File | undefined>();
|
|
|
|
private duckdbService = inject(DuckdbService);
|
|
|
|
// Can't be computed since effect has to run first so file exists in duckdb
|
|
columns = model<Column[]>([]);
|
|
protected aggregates = signal<(Aggregate | undefined)[]>([]);
|
|
protected currentAggregateColumn = signal<number | undefined>(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<any[]>([]);
|
|
protected appliedFilters = signal<Filter[]>([]);
|
|
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';
|
|
}
|
|
}
|