feat(table): add data table with sort, filter and pivot
This commit is contained in:
1 parent
9b8bc381a0
commit
664fb108e1
1 file changed
+216
@@ -0,0 +1,216 @@
|
|||||||
|
import 'address.dart';
|
||||||
|
import 'sheet.dart';
|
||||||
|
import 'values.dart';
|
||||||
|
|
||||||
|
enum Aggregator { sum, count, average, min, max, first }
|
||||||
|
|
||||||
|
class Aggregation {
|
||||||
|
final String column;
|
||||||
|
final Aggregator aggregator;
|
||||||
|
final String alias;
|
||||||
|
const Aggregation(this.column, this.aggregator, this.alias);
|
||||||
|
}
|
||||||
|
|
||||||
|
class DataTable {
|
||||||
|
final List<String> headers;
|
||||||
|
final List<List<CellValue>> rows;
|
||||||
|
|
||||||
|
DataTable(this.headers, this.rows);
|
||||||
|
|
||||||
|
factory DataTable.fromSheet(
|
||||||
|
Sheet sheet, {
|
||||||
|
CellAddress? topLeft,
|
||||||
|
CellAddress? bottomRight,
|
||||||
|
bool headerRow = true,
|
||||||
|
}) {
|
||||||
|
final origin = topLeft ?? const CellAddress(0, 0);
|
||||||
|
final dimensions = sheet.dimensions();
|
||||||
|
final end = bottomRight ?? CellAddress(dimensions.cols - 1, dimensions.rows - 1);
|
||||||
|
final grid = <List<CellValue>>[];
|
||||||
|
for (var row = origin.row; row <= end.row; row++) {
|
||||||
|
final record = <CellValue>[];
|
||||||
|
for (var col = origin.col; col <= end.col; col++) {
|
||||||
|
record.add(sheet.valueAt(CellAddress(col, row)));
|
||||||
|
}
|
||||||
|
grid.add(record);
|
||||||
|
}
|
||||||
|
if (headerRow && grid.isNotEmpty) {
|
||||||
|
final headers = [for (final cell in grid.first) cell.display()];
|
||||||
|
return DataTable(headers, grid.skip(1).toList());
|
||||||
|
}
|
||||||
|
final width = grid.isEmpty ? end.col - origin.col + 1 : grid.first.length;
|
||||||
|
final headers = [for (var i = 0; i < width; i++) columnLabel(origin.col + i)];
|
||||||
|
return DataTable(headers, grid);
|
||||||
|
}
|
||||||
|
|
||||||
|
int get rowCount => rows.length;
|
||||||
|
|
||||||
|
int columnIndex(String header) {
|
||||||
|
final index = headers.indexOf(header);
|
||||||
|
if (index < 0) throw ArgumentError('No such column: $header');
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
CellValue cell(int row, String column) => rows[row][columnIndex(column)];
|
||||||
|
|
||||||
|
DataTable sortBy(String column, {bool ascending = true}) {
|
||||||
|
final index = columnIndex(column);
|
||||||
|
final sorted = [...rows]
|
||||||
|
..sort((a, b) {
|
||||||
|
final result = compareValues(a[index], b[index]);
|
||||||
|
return ascending ? result : -result;
|
||||||
|
});
|
||||||
|
return DataTable(headers, sorted);
|
||||||
|
}
|
||||||
|
|
||||||
|
DataTable sortByAll(List<String> columns, {List<bool>? ascending}) {
|
||||||
|
final indices = [for (final column in columns) columnIndex(column)];
|
||||||
|
final directions = ascending ?? List<bool>.filled(columns.length, true);
|
||||||
|
final sorted = [...rows]
|
||||||
|
..sort((a, b) {
|
||||||
|
for (var i = 0; i < indices.length; i++) {
|
||||||
|
final result = compareValues(a[indices[i]], b[indices[i]]);
|
||||||
|
if (result != 0) return directions[i] ? result : -result;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
return DataTable(headers, sorted);
|
||||||
|
}
|
||||||
|
|
||||||
|
DataTable filterRows(bool Function(List<CellValue>) predicate) =>
|
||||||
|
DataTable(headers, rows.where(predicate).toList());
|
||||||
|
|
||||||
|
DataTable where(String column, bool Function(CellValue) test) {
|
||||||
|
final index = columnIndex(column);
|
||||||
|
return filterRows((row) => test(row[index]));
|
||||||
|
}
|
||||||
|
|
||||||
|
DataTable selectColumns(List<String> columns) {
|
||||||
|
final indices = [for (final column in columns) columnIndex(column)];
|
||||||
|
return DataTable(
|
||||||
|
columns,
|
||||||
|
[for (final row in rows) [for (final index in indices) row[index]]],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DataTable groupBy(List<String> keyColumns, List<Aggregation> aggregations) {
|
||||||
|
final keyIndices = [for (final column in keyColumns) columnIndex(column)];
|
||||||
|
final valueIndices = [for (final aggregation in aggregations) columnIndex(aggregation.column)];
|
||||||
|
final order = <String>[];
|
||||||
|
final keys = <String, List<CellValue>>{};
|
||||||
|
final buckets = <String, List<List<CellValue>>>{};
|
||||||
|
for (final row in rows) {
|
||||||
|
final keyValues = [for (final index in keyIndices) row[index]];
|
||||||
|
final signature = keyValues.map((value) => value.display()).join('');
|
||||||
|
if (!buckets.containsKey(signature)) {
|
||||||
|
order.add(signature);
|
||||||
|
keys[signature] = keyValues;
|
||||||
|
buckets[signature] = [];
|
||||||
|
}
|
||||||
|
buckets[signature]!.add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
final resultHeaders = [...keyColumns, for (final aggregation in aggregations) aggregation.alias];
|
||||||
|
final resultRows = <List<CellValue>>[];
|
||||||
|
for (final signature in order) {
|
||||||
|
final record = <CellValue>[...keys[signature]!];
|
||||||
|
for (var i = 0; i < aggregations.length; i++) {
|
||||||
|
final columnValues = [for (final row in buckets[signature]!) row[valueIndices[i]]];
|
||||||
|
record.add(aggregateValues(columnValues, aggregations[i].aggregator));
|
||||||
|
}
|
||||||
|
resultRows.add(record);
|
||||||
|
}
|
||||||
|
return DataTable(resultHeaders, resultRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
PivotTable pivot({
|
||||||
|
required String rowKey,
|
||||||
|
required String columnKey,
|
||||||
|
required String valueColumn,
|
||||||
|
Aggregator aggregator = Aggregator.sum,
|
||||||
|
}) {
|
||||||
|
final rowIndex = columnIndex(rowKey);
|
||||||
|
final colIndex = columnIndex(columnKey);
|
||||||
|
final valueIndex = columnIndex(valueColumn);
|
||||||
|
|
||||||
|
final rowLabels = <String>[];
|
||||||
|
final colLabels = <String>[];
|
||||||
|
final buckets = <String, Map<String, List<CellValue>>>{};
|
||||||
|
for (final row in rows) {
|
||||||
|
final rowLabel = row[rowIndex].display();
|
||||||
|
final colLabel = row[colIndex].display();
|
||||||
|
if (!rowLabels.contains(rowLabel)) rowLabels.add(rowLabel);
|
||||||
|
if (!colLabels.contains(colLabel)) colLabels.add(colLabel);
|
||||||
|
final byColumn = buckets.putIfAbsent(rowLabel, () => {});
|
||||||
|
byColumn.putIfAbsent(colLabel, () => []).add(row[valueIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
final matrix = <List<CellValue>>[];
|
||||||
|
for (final rowLabel in rowLabels) {
|
||||||
|
final record = <CellValue>[];
|
||||||
|
for (final colLabel in colLabels) {
|
||||||
|
final cellValues = buckets[rowLabel]?[colLabel];
|
||||||
|
record.add(cellValues == null
|
||||||
|
? const BlankValue()
|
||||||
|
: aggregateValues(cellValues, aggregator));
|
||||||
|
}
|
||||||
|
matrix.add(record);
|
||||||
|
}
|
||||||
|
return PivotTable(rowKey, rowLabels, colLabels, matrix);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<List<String>> toRows({bool includeHeader = true}) => [
|
||||||
|
if (includeHeader) headers,
|
||||||
|
for (final row in rows) [for (final cell in row) cell.display()],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
class PivotTable {
|
||||||
|
final String rowKeyName;
|
||||||
|
final List<String> rowLabels;
|
||||||
|
final List<String> columnLabels;
|
||||||
|
final List<List<CellValue>> values;
|
||||||
|
|
||||||
|
PivotTable(this.rowKeyName, this.rowLabels, this.columnLabels, this.values);
|
||||||
|
|
||||||
|
CellValue at(String rowLabel, String columnLabel) {
|
||||||
|
final r = rowLabels.indexOf(rowLabel);
|
||||||
|
final c = columnLabels.indexOf(columnLabel);
|
||||||
|
if (r < 0 || c < 0) return const BlankValue();
|
||||||
|
return values[r][c];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<List<String>> toRows() => [
|
||||||
|
[rowKeyName, ...columnLabels],
|
||||||
|
for (var r = 0; r < rowLabels.length; r++)
|
||||||
|
[rowLabels[r], for (final cell in values[r]) cell.display()],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
CellValue aggregateValues(List<CellValue> values, Aggregator aggregator) {
|
||||||
|
final numbers = [for (final value in values) if (value is NumberValue) value.value];
|
||||||
|
switch (aggregator) {
|
||||||
|
case Aggregator.count:
|
||||||
|
return NumberValue(values.where((value) => value is! BlankValue).length.toDouble());
|
||||||
|
case Aggregator.first:
|
||||||
|
return values.isEmpty ? const BlankValue() : values.first;
|
||||||
|
case Aggregator.sum:
|
||||||
|
return NumberValue(numbers.fold(0.0, (a, b) => a + b));
|
||||||
|
case Aggregator.average:
|
||||||
|
if (numbers.isEmpty) return const BlankValue();
|
||||||
|
return NumberValue(numbers.reduce((a, b) => a + b) / numbers.length);
|
||||||
|
case Aggregator.min:
|
||||||
|
if (numbers.isEmpty) return const BlankValue();
|
||||||
|
return NumberValue(numbers.reduce((a, b) => a < b ? a : b));
|
||||||
|
case Aggregator.max:
|
||||||
|
if (numbers.isEmpty) return const BlankValue();
|
||||||
|
return NumberValue(numbers.reduce((a, b) => a > b ? a : b));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int compareValues(CellValue a, CellValue b) {
|
||||||
|
final aNumber = a is NumberValue ? a.value : null;
|
||||||
|
final bNumber = b is NumberValue ? b.value : null;
|
||||||
|
if (aNumber != null && bNumber != null) return aNumber.compareTo(bNumber);
|
||||||
|
return a.display().toUpperCase().compareTo(b.display().toUpperCase());
|
||||||
|
}
|
||||||
Reference in new issue
Block a user