Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ export const DVTerms = {
dateValue: _t("The value must be a date"),
validRange: _t("The value must be a valid range"),
validFormula: _t("The formula must be valid"),
positiveNumber: _t("The value must be a positive number"),
},
Errors: {
[CommandResult.InvalidRange]: _t("The range is invalid."),
Expand Down
1 change: 1 addition & 0 deletions packages/o-spreadsheet-engine/src/helpers/locale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ function changeCFRuleLocale(
case "isLessThan":
case "isLessOrEqualTo":
case "customFormula":
case "top10":
rule.values = rule.values.map((v) => changeContentLocale(v));
return rule;
case "beginsWithText":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,14 @@ export class EvaluationConditionalFormatPlugin extends CoreViewPlugin {
const formulas = cf.rule.values.map((value) =>
value.startsWith("=") ? compile(value) : undefined
);
const evaluator = criterionEvaluatorRegistry.get(cf.rule.operator);
const criterion = { ...cf.rule, type: cf.rule.operator };
const ranges = cf.ranges.map((xc) => this.getters.getRangeFromSheetXC(sheetId, xc));
const preComputedCriterion = evaluator.preComputeCriterion?.(
criterion,
ranges,
this.getters
);
Comment on lines +116 to +123
Copy link
Contributor Author

@hokolomopo hokolomopo Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a huge fan, but top10 do a sort to get the top X values, and we really don't wanna sort the range once for every cell of the range, it will explode on big ranges. Even if we use a better algorithm than sorting the range to find the top 10, the best case is still O(n²) ...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name preComputeCriterion/preComputedCriterion also kinda sucks, I'm open to propositions

for (const ref of cf.ranges) {
const zone: Zone = this.getters.getRangeFromSheetXC(sheetId, ref).zone;
for (let row = zone.top; row <= zone.bottom; row++) {
Expand All @@ -130,7 +138,9 @@ export class EvaluationConditionalFormatPlugin extends CoreViewPlugin {
}
return value;
});
if (this.getRuleResultForTarget(target, { ...cf.rule, values })) {
if (
this.getRuleResultForTarget(target, { ...cf.rule, values }, preComputedCriterion)
) {
if (!computedStyle[col]) computedStyle[col] = [];
// we must combine all the properties of all the CF rules applied to the given cell
computedStyle[col][row] = Object.assign(
Expand Down Expand Up @@ -353,7 +363,11 @@ export class EvaluationConditionalFormatPlugin extends CoreViewPlugin {
}
}

private getRuleResultForTarget(target: CellPosition, rule: CellIsRule): boolean {
private getRuleResultForTarget(
target: CellPosition,
rule: CellIsRule,
preComputedCriterion: any
): boolean {
const cell: EvaluatedCell = this.getters.getEvaluatedCell(target);
if (cell.type === CellValueType.error) {
return false;
Expand All @@ -374,9 +388,10 @@ export class EvaluationConditionalFormatPlugin extends CoreViewPlugin {
}

const evaluatedCriterion = {
...rule,
type: rule.operator,
values: evaluatedCriterionValues.map(toScalar),
};
return evaluator.isValueValid(cell.value ?? "", evaluatedCriterion, this.getters, sheetId);
return evaluator.isValueValid(cell.value ?? "", evaluatedCriterion, preComputedCriterion);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
DataValidationCriterionType,
DataValidationRule,
} from "../../types/data_validation";
import { EvaluatedCriterion } from "../../types/generic_criterion";
import { GenericCriterion } from "../../types/generic_criterion";
import { DEFAULT_LOCALE } from "../../types/locale";
import { CellPosition, HeaderIndex, Lazy, Matrix, Offset, Style, UID } from "../../types/misc";
import { CoreViewPlugin } from "../core_view_plugin";
Expand Down Expand Up @@ -50,6 +50,7 @@ export class EvaluationDataValidationPlugin extends CoreViewPlugin {
] as const;

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

handle(cmd: CoreViewCommand) {
if (
Expand All @@ -58,12 +59,14 @@ export class EvaluationDataValidationPlugin extends CoreViewPlugin {
(cmd.type === "UPDATE_CELL" && ("content" in cmd || "format" in cmd))
) {
this.validationResults = {};
this.criterionPreComputeResult = {};
return;
}
switch (cmd.type) {
case "ADD_DATA_VALIDATION_RULE":
case "REMOVE_DATA_VALIDATION_RULE":
delete this.validationResults[cmd.sheetId];
delete this.criterionPreComputeResult[cmd.sheetId];
break;
}
}
Expand Down Expand Up @@ -115,7 +118,7 @@ export class EvaluationDataValidationPlugin extends CoreViewPlugin {

getDataValidationRangeValues(
sheetId: UID,
criterion: EvaluatedCriterion
criterion: GenericCriterion
): { value: string; label: string }[] {
const range = this.getters.getRangeFromSheetXC(sheetId, String(criterion.values[0]));
const values: { label: string; value: string }[] = [];
Expand Down Expand Up @@ -244,7 +247,20 @@ export class EvaluationDataValidationPlugin extends CoreViewPlugin {
}
const evaluatedCriterion = { ...criterion, values: evaluatedCriterionValues.map(toScalar) };

if (evaluator.isValueValid(cellValue, evaluatedCriterion, this.getters, sheetId)) {
if (!this.criterionPreComputeResult[sheetId]) {
this.criterionPreComputeResult[sheetId] = {};
}
let preComputedCriterion = this.criterionPreComputeResult[sheetId][rule.id];
if (preComputedCriterion === undefined) {
preComputedCriterion = evaluator.preComputeCriterion?.(
rule.criterion,
rule.ranges,
this.getters
);
this.criterionPreComputeResult[sheetId][rule.id] = preComputedCriterion;
}

if (evaluator.isValueValid(cellValue, evaluatedCriterion, preComputedCriterion)) {
return undefined;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { toLowerCase } from "../../helpers/text_helper";
import { positions, toZone, zoneToDimension } from "../../helpers/zones";
import { criterionEvaluatorRegistry } from "../../registries/criterion_registry";
import { Command, CommandResult, LocalCommand, UpdateFilterCommand } from "../../types/commands";
import { GenericCriterion } from "../../types/generic_criterion";
import { DEFAULT_LOCALE } from "../../types/locale";
import { CellPosition, FilterId, UID } from "../../types/misc";
import { CriterionFilter, DataFilterValue, Table } from "../../types/table";
Expand Down Expand Up @@ -175,6 +176,11 @@ export class FilterEvaluationPlugin extends UIPlugin {
} else {
if (filterValue.type === "none") continue;
const evaluator = criterionEvaluatorRegistry.get(filterValue.type);
const preComputedCriterion = evaluator.preComputeCriterion?.(
filterValue as GenericCriterion,
[filter.filteredRange],
this.getters
);

const evaluatedCriterionValues = filterValue.values.map((value) => {
if (!value.startsWith("=")) {
Expand All @@ -194,7 +200,7 @@ export class FilterEvaluationPlugin extends UIPlugin {
for (let row = filteredZone.top; row <= filteredZone.bottom; row++) {
const position = { sheetId, col: filter.col, row };
const value = this.getters.getEvaluatedCell(position).value ?? "";
if (!evaluator.isValueValid(value, evaluatedCriterion, this.getters, sheetId)) {
if (!evaluator.isValueValid(value, evaluatedCriterion, preComputedCriterion)) {
hiddenRows.add(row);
}
}
Expand Down
117 changes: 101 additions & 16 deletions packages/o-spreadsheet-engine/src/registries/criterion_registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
import { formatValue } from "../helpers/format/format";
import { detectLink } from "../helpers/links";
import { localizeContent } from "../helpers/locale";
import { isNumberBetween } from "../helpers/misc";
import { clip, isNumberBetween } from "../helpers/misc";
import { rangeReference } from "../helpers/references";
import { _t } from "../translation";
import { CellValue } from "../types/cells";
Expand All @@ -30,6 +30,7 @@ import {
DateIsNotBetweenCriterion,
DateIsOnOrAfterCriterion,
DateIsOnOrBeforeCriterion,
Top10Criterion,
} from "../types/data_validation";
import { CellErrorType } from "../types/errors";
import {
Expand All @@ -41,22 +42,34 @@ import {
import { Getters } from "../types/getters";
import { DEFAULT_LOCALE, Locale } from "../types/locale";
import { UID } from "../types/misc";
import { Range } from "../types/range";
import { Registry } from "./registry";

export type CriterionEvaluator = {
export type CriterionEvaluator<T = any> = {
type: GenericCriterionType;
/**
* Checks if a value is valid for the given criterion.
*
* The value and the criterion values should be in canonical form (non-localized), and formulas should
* be evaluated.
*
* For more complex criteria (like "top10"), a computation cache may be returned to avoid recomputing the entire criterion
* on every value it applies to.
*/
isValueValid: (
value: CellValue,
criterion: EvaluatedCriterion,
getters: Getters,
sheetId: UID
preComputedCriterion?: T
) => boolean;
/**
* For more complex criteria (like "top10"), we might want to pre-compute some data before evaluating the criterion for
* each cell, to avoid recomputing everything each time.
*/
preComputeCriterion?: (
criterion: GenericCriterion,
criterionRanges: Range[],
getters: Getters
) => T;
/**
* Returns the error string to display when the value is not valid.
*
Expand Down Expand Up @@ -617,20 +630,20 @@ criterionEvaluatorRegistry.add("isValueInList", {
});

criterionEvaluatorRegistry.add("isValueInRange", {
type: "isValueInList",
isValueValid: (
value: CellValue,
criterion: EvaluatedCriterion,
getters: Getters,
sheetId: UID
) => {
type: "isValueInRange",
preComputeCriterion: (criterion, criterionRanges: Range[], getters: Getters): Set<String> => {
if (criterionRanges.length === 0) {
return new Set();
}
const sheetId = criterionRanges[0].sheetId;
const criterionValues = getters.getDataValidationRangeValues(sheetId, criterion);
return new Set(criterionValues.map((value) => value.value.toString().toLowerCase()));
},
isValueValid: (value: CellValue, criterion: EvaluatedCriterion, valuesSet: Set<String>) => {
if (!value) {
return false;
}
const criterionValues = getters.getDataValidationRangeValues(sheetId, criterion);
return criterionValues
.map((value) => value.value.toLowerCase())
.includes(value.toString().toLowerCase());
return valuesSet.has(value.toString().toLowerCase());
},
getErrorString: (criterion: EvaluatedCriterion) =>
_t("The value must be a value in the range %s", String(criterion.values[0])),
Expand All @@ -640,7 +653,7 @@ criterionEvaluatorRegistry.add("isValueInRange", {
allowedValues: "onlyLiterals",
name: _t("Value in range"),
getPreview: (criterion) => _t("Value in range %s", criterion.values[0]),
});
} satisfies CriterionEvaluator<Set<String>>);

criterionEvaluatorRegistry.add("customFormula", {
type: "customFormula",
Expand Down Expand Up @@ -716,6 +729,73 @@ criterionEvaluatorRegistry.add("isNotEmpty", {
getPreview: () => _t("Is not empty"),
});

criterionEvaluatorRegistry.add("top10", {
type: "top10",
preComputeCriterion: (
criterion: Top10Criterion,
criterionRanges: Range[],
getters: Getters
): number | undefined => {
let value = tryToNumber(criterion.values[0], DEFAULT_LOCALE);
if (value === undefined || value <= 0) {
return undefined;
}

const numberValues: number[] = [];
for (const range of criterionRanges) {
for (const value of getters.getRangeValues(range)) {
if (typeof value === "number") {
numberValues.push(value);
}
}
}

const sortedValues = numberValues.sort((a, b) => a - b);
if (criterion.isPercent) {
value = clip(value, 1, 100);
}

let index = 0;
if (criterion.isBottom && !criterion.isPercent) {
index = value - 1;
} else if (criterion.isBottom && criterion.isPercent) {
index = Math.floor((sortedValues.length * value) / 100) - 1;
} else if (!criterion.isBottom && criterion.isPercent) {
index = sortedValues.length - Math.floor((sortedValues.length * value) / 100);
} else {
index = sortedValues.length - value;
}

index = clip(index, 0, sortedValues.length - 1);
return sortedValues[index];
},
isValueValid: (value: CellValue, criterion: EvaluatedCriterion<Top10Criterion>, threshold) => {
if (typeof value !== "number" || threshold === undefined) {
return false;
}
return criterion.isBottom ? value <= threshold : value >= threshold;
},
getErrorString: (criterion: EvaluatedCriterion<Top10Criterion>) => {
const args = {
value: String(criterion.values[0]),
percentSymbol: criterion.isPercent ? "%" : "",
};
return criterion.isBottom
? _t("The value must be in bottom %(value)s%(percentSymbol)s", args)
: _t("The value must be in top %(value)s%(percentSymbol)s", args);
},
isCriterionValueValid: (value) => checkValueIsPositiveNumber(value),
criterionValueErrorString: DVTerms.CriterionError.positiveNumber,
numberOfValues: () => 1,
name: _t("Is in Top/Bottom ranking"),
getPreview: (criterion: Top10Criterion) => {
const args = { value: criterion.values[0], percentSymbol: criterion.isPercent ? "%" : "" };
return criterion.isBottom
? _t("Value is in bottom %(value)s%(percentSymbol)s", args)
: _t("Value is in top %(value)s%(percentSymbol)s", args);
},
} satisfies CriterionEvaluator<number | undefined>);

function getNumberCriterionlocalizedValues(
criterion: EvaluatedCriterion,
locale: Locale
Expand Down Expand Up @@ -748,3 +828,8 @@ function checkValueIsNumber(value: string): boolean {
const valueAsNumber = tryToNumber(value, DEFAULT_LOCALE);
return valueAsNumber !== undefined;
}

function checkValueIsPositiveNumber(value: string): boolean {
const valueAsNumber = tryToNumber(value, DEFAULT_LOCALE);
return valueAsNumber !== undefined && valueAsNumber > 0;
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export interface CellIsRule extends SingleColorRule {
operator: ConditionalFormattingOperatorValues;
// can be one value for all operator except between, then it is 2 values
values: string[];
isPercent?: boolean;
isBottom?: boolean;
}
export interface ExpressionRule extends SingleColorRule {
type: "ExpressionRule";
Expand Down Expand Up @@ -143,15 +145,6 @@ export interface AboveAverageRule extends SingleColorRule {
equalAverage: boolean;
}

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

export const availableConditionalFormatOperators: Set<ConditionalFormattingOperatorValues> =
Expand All @@ -188,4 +182,5 @@ export const availableConditionalFormatOperators: Set<ConditionalFormattingOpera
"isNotEqual",
"isEqual",
"customFormula",
"top10",
]);
7 changes: 7 additions & 0 deletions packages/o-spreadsheet-engine/src/types/data_validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,13 @@ export type IsValueInRangeCriterion = {
displayStyle: "arrow" | "plainText" | "chip";
};

export type Top10Criterion = {
type: "top10";
values: string[];
isPercent?: boolean;
isBottom?: boolean;
};

export type CustomFormulaCriterion = {
type: "customFormula";
values: string[];
Expand Down
Loading