You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
gristlabs_grist-core/app/common/SortFunc.ts

162 lines
5.7 KiB

/**
* SortFunc class interprets the sortSpec (as saved in viewSection.sortColRefs), exposing a
* compare(rowId1, rowId2) function that can be used to actually sort rows in a view.
*
* TODO: When an operation (such as a paste) would cause rows to jump in the sort order, this
* class should support freezing of row positions until the user chooses to re-sort. This is not
* currently implemented.
*/
import {ColumnGetter, ColumnGetters} from 'app/common/ColumnGetters';
import {localeCompare, nativeCompare} from 'app/common/gutil';
import {Sort} from 'app/common/SortSpec';
// Function that will amend column getter to return entry index instead
// of entry value. Result will be a string padded with zeros, so the ordering
// between types is preserved.
export function choiceGetter(getter: ColumnGetter, choices: string[]): ColumnGetter {
return rowId => {
const value = getter(rowId);
const index = choices.indexOf(value);
return index >= 0 ? String(index).padStart(5, "0") : value;
};
}
type Comparator = (val1: any, val2: any) => number;
/**
* Natural comparator based on built in method.
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/localeCompare
*/
const collator = new Intl.Collator(undefined, {numeric: true});
export function naturalCompare(val1: any, val2: any) {
if (typeof val1 === 'string' && typeof val2 === 'string') {
return collator.compare(val1, val2);
}
return typedCompare(val1, val2);
}
/**
* Empty comparator will treat empty values as last.
*/
export const emptyCompare = (next: Comparator) => (val1: any, val2: any) => {
const isEmptyValue1 = !val1 && typeof val1 !== 'number';
const isEmptyValue2 = !val2 && typeof val2 !== 'number';
// If both values are empty values, rely on next to compare.
if (isEmptyValue1 && !isEmptyValue2) {
return 1;
}
if (isEmptyValue2 && !isEmptyValue1) {
return -1;
}
return next(val1, val2);
};
/**
* Compare two cell values, paying attention to types and values. Note that native JS comparison
* can't be used for sorting because it isn't transitive across types (e.g. both 1 < "2" and "2" <
* "a" are true, but 1 < "a" is false.). In addition, we handle complex values represented in
* Grist as arrays.
*
* Note that we need to handle different types of values regardless of the column type,
* because e.g. a numerical column may contain text (alttext) or null values.
*/
export function typedCompare(val1: any, val2: any): number {
let result: number, type1: string, array1: boolean;
// tslint:disable-next-line:no-conditional-assignment
if ((result = nativeCompare(type1 = typeof val1, typeof val2)) !== 0) {
return result;
}
// We need to worry about Array comparisons because formulas returning Any may return null or
// object values represented as arrays (e.g. ['D', ...] for dates). Comparing those without
// distinguishing types would break the sort. Also, arrays need a special comparator.
if (type1 === 'object') {
// tslint:disable-next-line:no-conditional-assignment
if ((result = nativeCompare(array1 = val1 instanceof Array, val2 instanceof Array)) !== 0) {
return result;
}
if (array1) {
return _arrayCompare(val1, val2);
}
}
if (type1 === 'string') {
return localeCompare(val1, val2);
}
return nativeCompare(val1, val2);
}
function _arrayCompare(val1: any[], val2: any[]): number {
for (let i = 0; i < val1.length; i++) {
if (i >= val2.length) {
return 1;
}
const value = typedCompare(val1[i], val2[i]);
if (value) {
return value;
}
}
return val1.length === val2.length ? 0 : -1;
}
/**
* getters is an implementation of app.common.ColumnGetters
*/
export class SortFunc {
// updateSpec() or updateGetters() can populate these fields, used by the compare() method.
private _colGetters: ColumnGetter[] = []; // Array of column getters (mapping rowId to column value)
private _directions: number[] = []; // Array of 1 (ascending) or -1 (descending) flags.
private _comparators: Comparator[] = [];
constructor(private _getters: ColumnGetters) {}
public updateSpec(sortSpec: Sort.SortSpec): void {
// Prepare an array of column getters for each column in sortSpec.
this._colGetters = sortSpec.map(colSpec => {
return this._getters.getColGetter(colSpec);
}).filter(getter => getter) as ColumnGetter[];
// Collect "ascending" flags as an array of 1 or -1, one for each column.
this._directions = sortSpec.map(colSpec => Sort.direction(colSpec));
// Collect comparator functions
this._comparators = sortSpec.map(colSpec => {
const details = Sort.specToDetails(colSpec);
let comparator = typedCompare;
if (details.naturalSort) {
comparator = naturalCompare;
}
// Empty decorator should be added last, as first we want to compare
// empty values
if (details.emptyLast) {
comparator = emptyCompare(comparator);
}
return comparator;
});
const manualSortGetter = this._getters.getManualSortGetter();
if (manualSortGetter) {
this._colGetters.push(manualSortGetter);
this._directions.push(1);
this._comparators.push(typedCompare);
}
}
/**
* Returns 1 or -1 depending on whether rowId1 should be shown before rowId2.
*/
public compare(rowId1: number, rowId2: number): number {
for (let i = 0, len = this._colGetters.length; i < len; i++) {
const getter = this._colGetters[i];
const val1 = getter(rowId1);
const val2 = getter(rowId2);
const comparator = this._comparators[i];
const result = comparator(val1, val2);
if (result !== 0 /* not equal */) {
return result * this._directions[i];
}
}
return nativeCompare(rowId1, rowId2);
}
}