Skip to content

Commit 7e1c6a3

Browse files
committed
[IMP] cf: add top10 conditional formatting operator
This commits adds the "top10" conditional formatting operator, which highlights the top or bottom N values in a selected range. Task: 5367065
1 parent a95d9a4 commit 7e1c6a3

File tree

26 files changed

+467
-62
lines changed

26 files changed

+467
-62
lines changed

packages/o-spreadsheet-engine/src/components/translations_terms.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ export const DVTerms = {
143143
dateValue: _t("The value must be a date"),
144144
validRange: _t("The value must be a valid range"),
145145
validFormula: _t("The formula must be valid"),
146+
positiveNumber: _t("The value must be a positive number"),
146147
},
147148
Errors: {
148149
[CommandResult.InvalidRange]: _t("The range is invalid."),

packages/o-spreadsheet-engine/src/helpers/locale.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,7 @@ function changeCFRuleLocale(
276276
case "isLessThan":
277277
case "isLessOrEqualTo":
278278
case "customFormula":
279+
case "top10":
279280
rule.values = rule.values.map((v) => changeContentLocale(v));
280281
return rule;
281282
case "beginsWithText":

packages/o-spreadsheet-engine/src/plugins/ui_core_views/evaluation_conditional_format.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,14 @@ export class EvaluationConditionalFormatPlugin extends CoreViewPlugin {
113113
const formulas = cf.rule.values.map((value) =>
114114
value.startsWith("=") ? compile(value) : undefined
115115
);
116+
const evaluator = criterionEvaluatorRegistry.get(cf.rule.operator);
117+
const criterion = { ...cf.rule, type: cf.rule.operator };
118+
const ranges = cf.ranges.map((xc) => this.getters.getRangeFromSheetXC(sheetId, xc));
119+
const preComputedCriterion = evaluator.preComputeCriterion?.(
120+
criterion,
121+
ranges,
122+
this.getters
123+
);
116124
for (const ref of cf.ranges) {
117125
const zone: Zone = this.getters.getRangeFromSheetXC(sheetId, ref).zone;
118126
for (let row = zone.top; row <= zone.bottom; row++) {
@@ -130,7 +138,9 @@ export class EvaluationConditionalFormatPlugin extends CoreViewPlugin {
130138
}
131139
return value;
132140
});
133-
if (this.getRuleResultForTarget(target, { ...cf.rule, values })) {
141+
if (
142+
this.getRuleResultForTarget(target, { ...cf.rule, values }, preComputedCriterion)
143+
) {
134144
if (!computedStyle[col]) computedStyle[col] = [];
135145
// we must combine all the properties of all the CF rules applied to the given cell
136146
computedStyle[col][row] = Object.assign(
@@ -353,7 +363,11 @@ export class EvaluationConditionalFormatPlugin extends CoreViewPlugin {
353363
}
354364
}
355365

356-
private getRuleResultForTarget(target: CellPosition, rule: CellIsRule): boolean {
366+
private getRuleResultForTarget(
367+
target: CellPosition,
368+
rule: CellIsRule,
369+
preComputedCriterion: any
370+
): boolean {
357371
const cell: EvaluatedCell = this.getters.getEvaluatedCell(target);
358372
if (cell.type === CellValueType.error) {
359373
return false;
@@ -374,9 +388,10 @@ export class EvaluationConditionalFormatPlugin extends CoreViewPlugin {
374388
}
375389

376390
const evaluatedCriterion = {
391+
...rule,
377392
type: rule.operator,
378393
values: evaluatedCriterionValues.map(toScalar),
379394
};
380-
return evaluator.isValueValid(cell.value ?? "", evaluatedCriterion, this.getters, sheetId);
395+
return evaluator.isValueValid(cell.value ?? "", evaluatedCriterion, preComputedCriterion);
381396
}
382397
}

packages/o-spreadsheet-engine/src/plugins/ui_core_views/evaluation_data_validation.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
DataValidationCriterionType,
1717
DataValidationRule,
1818
} from "../../types/data_validation";
19-
import { EvaluatedCriterion } from "../../types/generic_criterion";
19+
import { GenericCriterion } from "../../types/generic_criterion";
2020
import { DEFAULT_LOCALE } from "../../types/locale";
2121
import { CellPosition, HeaderIndex, Lazy, Matrix, Offset, Style, UID } from "../../types/misc";
2222
import { CoreViewPlugin } from "../core_view_plugin";
@@ -50,6 +50,7 @@ export class EvaluationDataValidationPlugin extends CoreViewPlugin {
5050
] as const;
5151

5252
validationResults: Record<UID, SheetValidationResult> = {};
53+
criterionPreComputeResult: Record<UID, { [dvRuleId: UID]: any }> = {};
5354

5455
handle(cmd: CoreViewCommand) {
5556
if (
@@ -58,12 +59,14 @@ export class EvaluationDataValidationPlugin extends CoreViewPlugin {
5859
(cmd.type === "UPDATE_CELL" && ("content" in cmd || "format" in cmd))
5960
) {
6061
this.validationResults = {};
62+
this.criterionPreComputeResult = {};
6163
return;
6264
}
6365
switch (cmd.type) {
6466
case "ADD_DATA_VALIDATION_RULE":
6567
case "REMOVE_DATA_VALIDATION_RULE":
6668
delete this.validationResults[cmd.sheetId];
69+
delete this.criterionPreComputeResult[cmd.sheetId];
6770
break;
6871
}
6972
}
@@ -115,7 +118,7 @@ export class EvaluationDataValidationPlugin extends CoreViewPlugin {
115118

116119
getDataValidationRangeValues(
117120
sheetId: UID,
118-
criterion: EvaluatedCriterion
121+
criterion: GenericCriterion
119122
): { value: string; label: string }[] {
120123
const range = this.getters.getRangeFromSheetXC(sheetId, String(criterion.values[0]));
121124
const values: { label: string; value: string }[] = [];
@@ -244,7 +247,20 @@ export class EvaluationDataValidationPlugin extends CoreViewPlugin {
244247
}
245248
const evaluatedCriterion = { ...criterion, values: evaluatedCriterionValues.map(toScalar) };
246249

247-
if (evaluator.isValueValid(cellValue, evaluatedCriterion, this.getters, sheetId)) {
250+
if (!this.criterionPreComputeResult[sheetId]) {
251+
this.criterionPreComputeResult[sheetId] = {};
252+
}
253+
let preComputedCriterion = this.criterionPreComputeResult[sheetId][rule.id];
254+
if (preComputedCriterion === undefined) {
255+
preComputedCriterion = evaluator.preComputeCriterion?.(
256+
rule.criterion,
257+
rule.ranges,
258+
this.getters
259+
);
260+
this.criterionPreComputeResult[sheetId][rule.id] = preComputedCriterion;
261+
}
262+
263+
if (evaluator.isValueValid(cellValue, evaluatedCriterion, preComputedCriterion)) {
248264
return undefined;
249265
}
250266

packages/o-spreadsheet-engine/src/plugins/ui_stateful/filter_evaluation.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { toLowerCase } from "../../helpers/text_helper";
66
import { positions, toZone, zoneToDimension } from "../../helpers/zones";
77
import { criterionEvaluatorRegistry } from "../../registries/criterion_registry";
88
import { Command, CommandResult, LocalCommand, UpdateFilterCommand } from "../../types/commands";
9+
import { GenericCriterion } from "../../types/generic_criterion";
910
import { DEFAULT_LOCALE } from "../../types/locale";
1011
import { CellPosition, FilterId, UID } from "../../types/misc";
1112
import { CriterionFilter, DataFilterValue, Table } from "../../types/table";
@@ -175,6 +176,11 @@ export class FilterEvaluationPlugin extends UIPlugin {
175176
} else {
176177
if (filterValue.type === "none") continue;
177178
const evaluator = criterionEvaluatorRegistry.get(filterValue.type);
179+
const preComputedCriterion = evaluator.preComputeCriterion?.(
180+
filterValue as GenericCriterion,
181+
[filter.filteredRange],
182+
this.getters
183+
);
178184

179185
const evaluatedCriterionValues = filterValue.values.map((value) => {
180186
if (!value.startsWith("=")) {
@@ -194,7 +200,7 @@ export class FilterEvaluationPlugin extends UIPlugin {
194200
for (let row = filteredZone.top; row <= filteredZone.bottom; row++) {
195201
const position = { sheetId, col: filter.col, row };
196202
const value = this.getters.getEvaluatedCell(position).value ?? "";
197-
if (!evaluator.isValueValid(value, evaluatedCriterion, this.getters, sheetId)) {
203+
if (!evaluator.isValueValid(value, evaluatedCriterion, preComputedCriterion)) {
198204
hiddenRows.add(row);
199205
}
200206
}

packages/o-spreadsheet-engine/src/registries/criterion_registry.ts

Lines changed: 108 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
import { formatValue } from "../helpers/format/format";
1919
import { detectLink } from "../helpers/links";
2020
import { localizeContent } from "../helpers/locale";
21-
import { isNumberBetween } from "../helpers/misc";
21+
import { clip, isNumberBetween } from "../helpers/misc";
2222
import { rangeReference } from "../helpers/references";
2323
import { _t } from "../translation";
2424
import { CellValue } from "../types/cells";
@@ -30,6 +30,7 @@ import {
3030
DateIsNotBetweenCriterion,
3131
DateIsOnOrAfterCriterion,
3232
DateIsOnOrBeforeCriterion,
33+
Top10Criterion,
3334
} from "../types/data_validation";
3435
import { CellErrorType } from "../types/errors";
3536
import {
@@ -41,22 +42,34 @@ import {
4142
import { Getters } from "../types/getters";
4243
import { DEFAULT_LOCALE, Locale } from "../types/locale";
4344
import { UID } from "../types/misc";
45+
import { Range } from "../types/range";
4446
import { Registry } from "./registry";
4547

46-
export type CriterionEvaluator = {
48+
export type CriterionEvaluator<T = any> = {
4749
type: GenericCriterionType;
4850
/**
4951
* Checks if a value is valid for the given criterion.
5052
*
5153
* The value and the criterion values should be in canonical form (non-localized), and formulas should
5254
* be evaluated.
55+
*
56+
* For more complex criteria (like "top10"), a computation cache may be returned to avoid recomputing the entire criterion
57+
* on every value it applies to.
5358
*/
5459
isValueValid: (
5560
value: CellValue,
5661
criterion: EvaluatedCriterion,
57-
getters: Getters,
58-
sheetId: UID
62+
preComputedCriterion?: T
5963
) => boolean;
64+
/**
65+
* For more complex criteria (like "top10"), we might want to pre-compute some data before evaluating the criterion for
66+
* each cell, to avoid recomputing everything each time.
67+
*/
68+
preComputeCriterion?: (
69+
criterion: GenericCriterion,
70+
criterionRanges: Range[],
71+
getters: Getters
72+
) => T;
6073
/**
6174
* Returns the error string to display when the value is not valid.
6275
*
@@ -617,20 +630,20 @@ criterionEvaluatorRegistry.add("isValueInList", {
617630
});
618631

619632
criterionEvaluatorRegistry.add("isValueInRange", {
620-
type: "isValueInList",
621-
isValueValid: (
622-
value: CellValue,
623-
criterion: EvaluatedCriterion,
624-
getters: Getters,
625-
sheetId: UID
626-
) => {
633+
type: "isValueInRange",
634+
preComputeCriterion: (criterion, criterionRanges: Range[], getters: Getters): Set<String> => {
635+
if (criterionRanges.length === 0) {
636+
return new Set();
637+
}
638+
const sheetId = criterionRanges[0].sheetId;
639+
const criterionValues = getters.getDataValidationRangeValues(sheetId, criterion);
640+
return new Set(criterionValues.map((value) => value.value.toString().toLowerCase()));
641+
},
642+
isValueValid: (value: CellValue, criterion: EvaluatedCriterion, valuesSet: Set<String>) => {
627643
if (!value) {
628644
return false;
629645
}
630-
const criterionValues = getters.getDataValidationRangeValues(sheetId, criterion);
631-
return criterionValues
632-
.map((value) => value.value.toLowerCase())
633-
.includes(value.toString().toLowerCase());
646+
return valuesSet.has(value.toString().toLowerCase());
634647
},
635648
getErrorString: (criterion: EvaluatedCriterion) =>
636649
_t("The value must be a value in the range %s", String(criterion.values[0])),
@@ -640,7 +653,7 @@ criterionEvaluatorRegistry.add("isValueInRange", {
640653
allowedValues: "onlyLiterals",
641654
name: _t("Value in range"),
642655
getPreview: (criterion) => _t("Value in range %s", criterion.values[0]),
643-
});
656+
} satisfies CriterionEvaluator<Set<String>>);
644657

645658
criterionEvaluatorRegistry.add("customFormula", {
646659
type: "customFormula",
@@ -716,6 +729,80 @@ criterionEvaluatorRegistry.add("isNotEmpty", {
716729
getPreview: () => _t("Is not empty"),
717730
});
718731

732+
criterionEvaluatorRegistry.add("top10", {
733+
type: "top10",
734+
preComputeCriterion: (
735+
criterion: Top10Criterion,
736+
criterionRanges: Range[],
737+
getters: Getters
738+
): number | undefined => {
739+
let value = tryToNumber(criterion.values[0], DEFAULT_LOCALE);
740+
if (value === undefined || value <= 0) {
741+
return undefined;
742+
}
743+
744+
const numberValues: number[] = [];
745+
for (const range of criterionRanges) {
746+
for (const value of getters.getRangeValues(range)) {
747+
if (typeof value === "number") {
748+
numberValues.push(value);
749+
}
750+
}
751+
}
752+
753+
const sortedValues = numberValues.sort((a, b) => a - b);
754+
if (!criterion.isPercent && sortedValues.length < value) {
755+
return undefined;
756+
} else if (criterion.isPercent) {
757+
value = clip(value, 1, 100);
758+
}
759+
760+
let index = 0;
761+
if (criterion.isBottom && !criterion.isPercent) {
762+
index = value - 1;
763+
} else if (criterion.isBottom && criterion.isPercent) {
764+
index = Math.floor((sortedValues.length * value) / 100) - 1;
765+
if (index < 0) {
766+
index = 0;
767+
}
768+
} else if (!criterion.isBottom && criterion.isPercent) {
769+
index = sortedValues.length - Math.floor((sortedValues.length * value) / 100);
770+
if (index === sortedValues.length) {
771+
index = sortedValues.length - 1;
772+
}
773+
} else {
774+
index = sortedValues.length - value;
775+
}
776+
777+
return sortedValues[index];
778+
},
779+
isValueValid: (value: CellValue, criterion: EvaluatedCriterion<Top10Criterion>, threshold) => {
780+
if (typeof value !== "number" || threshold === undefined) {
781+
return false;
782+
}
783+
return criterion.isBottom ? value <= threshold : value >= threshold;
784+
},
785+
getErrorString: (criterion: EvaluatedCriterion<Top10Criterion>) => {
786+
const args = {
787+
value: String(criterion.values[0]),
788+
percentSymbol: criterion.isPercent ? "%" : "",
789+
};
790+
return criterion.isBottom
791+
? _t("The value must be in bottom %(value)s%(percentSymbol)s", args)
792+
: _t("The value must be in top %(value)s%(percentSymbol)s", args);
793+
},
794+
isCriterionValueValid: (value) => checkValueIsPositiveNumber(value),
795+
criterionValueErrorString: DVTerms.CriterionError.positiveNumber,
796+
numberOfValues: () => 1,
797+
name: _t("Top/bottom X"),
798+
getPreview: (criterion: Top10Criterion) => {
799+
const args = { value: criterion.values[0], percentSymbol: criterion.isPercent ? "%" : "" };
800+
return criterion.isBottom
801+
? _t("Value is in bottom %(value)s%(percentSymbol)s", args)
802+
: _t("Value is in top %(value)s%(percentSymbol)s", args);
803+
},
804+
} satisfies CriterionEvaluator<number | undefined>);
805+
719806
function getNumberCriterionlocalizedValues(
720807
criterion: EvaluatedCriterion,
721808
locale: Locale
@@ -748,3 +835,8 @@ function checkValueIsNumber(value: string): boolean {
748835
const valueAsNumber = tryToNumber(value, DEFAULT_LOCALE);
749836
return valueAsNumber !== undefined;
750837
}
838+
839+
function checkValueIsPositiveNumber(value: string): boolean {
840+
const valueAsNumber = tryToNumber(value, DEFAULT_LOCALE);
841+
return valueAsNumber !== undefined && valueAsNumber > 0;
842+
}

packages/o-spreadsheet-engine/src/types/conditional_formatting.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ export interface CellIsRule extends SingleColorRule {
5454
operator: ConditionalFormattingOperatorValues;
5555
// can be one value for all operator except between, then it is 2 values
5656
values: string[];
57+
isPercent?: boolean;
58+
isBottom?: boolean;
5759
}
5860
export interface ExpressionRule extends SingleColorRule {
5961
type: "ExpressionRule";
@@ -143,15 +145,6 @@ export interface AboveAverageRule extends SingleColorRule {
143145
equalAverage: boolean;
144146
}
145147

146-
export interface Top10Rule extends SingleColorRule {
147-
type: "Top10Rule";
148-
percent: boolean;
149-
bottom: boolean;
150-
/* specifies how many cells are formatted by this conditional formatting rule. The value of percent specifies whether
151-
rank is a percentage or a quantity of cells. When percent is "true", rank MUST be greater than or equal to zero and
152-
less than or equal to 100. Otherwise, rank MUST be greater than or equal to 1 and less than or equal to 1,000 */
153-
rank: number;
154-
}
155148
//https://docs.microsoft.com/en-us/dotnet/api/documentformat.openxml.spreadsheet.conditionalformattingoperatorvalues?view=openxml-2.8.1
156149
// Note: IsEmpty and IsNotEmpty does not exist on the specification
157150
export type ConditionalFormattingOperatorValues =
@@ -169,6 +162,7 @@ export type ConditionalFormattingOperatorValues =
169162
| "isNotBetween"
170163
| "notContainsText"
171164
| "isNotEqual"
165+
| "top10"
172166
| "customFormula";
173167

174168
export const availableConditionalFormatOperators: Set<ConditionalFormattingOperatorValues> =
@@ -188,4 +182,5 @@ export const availableConditionalFormatOperators: Set<ConditionalFormattingOpera
188182
"isNotEqual",
189183
"isEqual",
190184
"customFormula",
185+
"top10",
191186
]);

packages/o-spreadsheet-engine/src/types/data_validation.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,13 @@ export type IsValueInRangeCriterion = {
138138
displayStyle: "arrow" | "plainText" | "chip";
139139
};
140140

141+
export type Top10Criterion = {
142+
type: "top10";
143+
values: string[];
144+
isPercent?: boolean;
145+
isBottom?: boolean;
146+
};
147+
141148
export type CustomFormulaCriterion = {
142149
type: "customFormula";
143150
values: string[];

0 commit comments

Comments
 (0)