2020-07-21 13:20:51 +00:00
|
|
|
/**
|
|
|
|
* 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.
|
|
|
|
*/
|
2021-11-03 11:44:28 +00:00
|
|
|
import {ColumnGetter, ColumnGetters} from 'app/common/ColumnGetters';
|
2021-03-13 02:25:44 +00:00
|
|
|
import {localeCompare, nativeCompare} from 'app/common/gutil';
|
2021-11-03 11:44:28 +00:00
|
|
|
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});
|
2024-04-05 08:54:53 +00:00
|
|
|
export function naturalCompare(val1: any, val2: any) {
|
2021-11-03 11:44:28 +00:00
|
|
|
if (typeof val1 === 'string' && typeof val2 === 'string') {
|
|
|
|
return collator.compare(val1, val2);
|
|
|
|
}
|
|
|
|
return typedCompare(val1, val2);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Empty comparator will treat empty values as last.
|
|
|
|
*/
|
2023-01-27 09:33:15 +00:00
|
|
|
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;
|
2021-11-03 11:44:28 +00:00
|
|
|
}
|
2023-01-27 09:33:15 +00:00
|
|
|
if (isEmptyValue2 && !isEmptyValue1) {
|
2021-11-03 11:44:28 +00:00
|
|
|
return -1;
|
|
|
|
}
|
|
|
|
return next(val1, val2);
|
|
|
|
};
|
|
|
|
|
2020-07-21 13:20:51 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* 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 {
|
2021-03-13 02:25:44 +00:00
|
|
|
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;
|
|
|
|
}
|
2022-02-19 09:46:49 +00:00
|
|
|
// We need to worry about Array comparisons because formulas returning Any may return null or
|
2021-03-13 02:25:44 +00:00
|
|
|
// 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);
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
function _arrayCompare(val1: any[], val2: any[]): number {
|
|
|
|
for (let i = 0; i < val1.length; i++) {
|
|
|
|
if (i >= val2.length) {
|
|
|
|
return 1;
|
|
|
|
}
|
2021-03-13 02:25:44 +00:00
|
|
|
const value = typedCompare(val1[i], val2[i]);
|
2020-07-21 13:20:51 +00:00
|
|
|
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)
|
2021-11-03 11:44:28 +00:00
|
|
|
private _directions: number[] = []; // Array of 1 (ascending) or -1 (descending) flags.
|
|
|
|
private _comparators: Comparator[] = [];
|
2020-07-21 13:20:51 +00:00
|
|
|
|
|
|
|
constructor(private _getters: ColumnGetters) {}
|
|
|
|
|
2021-11-03 11:44:28 +00:00
|
|
|
public updateSpec(sortSpec: Sort.SortSpec): void {
|
2020-07-21 13:20:51 +00:00
|
|
|
// Prepare an array of column getters for each column in sortSpec.
|
2021-11-03 11:44:28 +00:00
|
|
|
this._colGetters = sortSpec.map(colSpec => {
|
|
|
|
return this._getters.getColGetter(colSpec);
|
2020-07-21 13:20:51 +00:00
|
|
|
}).filter(getter => getter) as ColumnGetter[];
|
|
|
|
|
|
|
|
// Collect "ascending" flags as an array of 1 or -1, one for each column.
|
2021-11-03 11:44:28 +00:00
|
|
|
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;
|
|
|
|
});
|
2020-07-21 13:20:51 +00:00
|
|
|
|
|
|
|
const manualSortGetter = this._getters.getManualSortGetter();
|
|
|
|
if (manualSortGetter) {
|
|
|
|
this._colGetters.push(manualSortGetter);
|
2021-11-03 11:44:28 +00:00
|
|
|
this._directions.push(1);
|
|
|
|
this._comparators.push(typedCompare);
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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];
|
2021-11-03 11:44:28 +00:00
|
|
|
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];
|
2020-07-21 13:20:51 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
return nativeCompare(rowId1, rowId2);
|
|
|
|
}
|
|
|
|
}
|