Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: show active cell selection formula results in bottom bar #594

Merged
merged 17 commits into from
Jul 10, 2023
Merged
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
12 changes: 4 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion quadratic-core/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion quadratic-core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "quadratic-core"
version = "0.1.13"
version = "0.1.14"
authors = ["Andrew Farkas <andrew.farkas@quadratic.to>"]
edition = "2021"
description = "Infinite data grid with Python, JavaScript, and SQL built-in"
Expand Down
2 changes: 1 addition & 1 deletion quadratic-core/src/formulas/functions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ mod util;

use super::{
Array, Axis, BasicValue, CellRef, CoerceInto, Criterion, Ctx, FormulaError, FormulaErrorMsg,
FormulaResult, Param, ParamKind, Span, Spanned, SpannedIterExt, Value,
FormulaResult, IsBlank, Param, ParamKind, Span, Spanned, SpannedIterExt, Value,
};

pub fn lookup_function(name: &str) -> Option<&'static FormulaFunction> {
Expand Down
64 changes: 56 additions & 8 deletions quadratic-core/src/formulas/functions/statistics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,28 @@ fn get_functions() -> Vec<FormulaFunction> {
),
formula_fn!(
/// Returns the number of numeric values.
///
/// - Blank cells are not counted.
/// - Cells containing an error are not counted.
#[examples("COUNT(A1:C42, E17)", "SUM(A1:A10) / COUNT(A1:A10)")]
fn COUNT(numbers: (Iter<f64>)) {
fn COUNT(numbers: (Iter<BasicValue>)) {
// Ignore error values.
numbers.filter(|x| x.is_ok()).count() as f64
numbers
.filter(|x| matches!(x, Ok(BasicValue::Number(_))))
.count() as f64
}
),
formula_fn!(
/// Returns the number of non-blank values.
///
/// - Cells with formula or code output of an empty string are
/// counted.
/// - Cells containing zero are counted.
/// - Cells with an error are counted.
#[examples("COUNTA(A1:A10)")]
fn COUNTA(range: (Iter<BasicValue>)) {
// Count error values.
range.filter_ok(|v| !v.is_blank()).count() as f64
}
),
formula_fn!(
Expand All @@ -68,11 +86,15 @@ fn get_functions() -> Vec<FormulaFunction> {
}
),
formula_fn!(
/// Counts how many values in the range are empty. Cells with
/// formula or code output of an empty string are also counted.
/// Counts how many values in the range are empty.
///
/// - Cells with formula or code output of an empty string are
/// counted.
/// - Cells containing zero are not counted.
/// - Cells with an error are not counted.
#[examples("COUNTBLANK(A1:A10)")]
fn COUNTBLANK(range: (Iter<BasicValue>)) {
// Do not count error values.
// Ignore error values.
range
.filter_map(|v| v.ok())
.filter(|v| v.is_blank_or_empty_string())
Expand Down Expand Up @@ -183,15 +205,35 @@ mod tests {

#[test]
fn test_count() {
let g = &mut NoGrid;
let g = &mut BlankGrid;
assert_eq!("0", eval_to_string(g, "COUNT()"));
assert_eq!("0", eval_to_string(g, "COUNT(A1)"));
assert_eq!("0", eval_to_string(g, "COUNT(A1:B4)"));
assert_eq!(
"3",
eval_to_string(g, "COUNT(\"_\", \"a\", 12, -3.5, 42.5)"),
);
assert_eq!("1", eval_to_string(g, "COUNT(2)"));
assert_eq!("10", eval_to_string(g, "COUNT(1..10)"));
assert_eq!("11", eval_to_string(g, "COUNT(0..10)"));
assert_eq!("1", eval_to_string(g, "COUNT({\"\",1,,,})"));
}

#[test]
fn test_counta() {
let g = &mut BlankGrid;
assert_eq!("0", eval_to_string(g, "COUNTA()"));
assert_eq!("0", eval_to_string(g, "COUNTA(A1)"));
assert_eq!("0", eval_to_string(g, "COUNTA(A1:B4)"));
assert_eq!(
"5",
eval_to_string(g, "COUNTA(\"_\", \"a\", 12, -3.5, 42.5)"),
);
assert_eq!("1", eval_to_string(g, "COUNTA(\"\")"));
assert_eq!("1", eval_to_string(g, "COUNTA(2)"));
assert_eq!("10", eval_to_string(g, "COUNTA(1..10)"));
assert_eq!("11", eval_to_string(g, "COUNTA(0..10)"));
assert_eq!("2", eval_to_string(g, "COUNTA({\"\",1,,,})"));
}

#[test]
Expand All @@ -208,8 +250,14 @@ mod tests {
#[test]
fn test_countblank() {
let g = &mut BlankGrid;
assert_eq!("1", eval_to_string(g, "COUNTBLANK(\"\", \"a\", 0, 1)"));
assert_eq!("2", eval_to_string(g, "COUNTBLANK(B3, \"\", \"a\", 0, 1)"));
assert_eq!("1", eval_to_string(g, "COUNTBLANK(\"\")"));
assert_eq!("0", eval_to_string(g, "COUNTBLANK(\"a\")"));
assert_eq!("0", eval_to_string(g, "COUNTBLANK(0)"));
assert_eq!("0", eval_to_string(g, "COUNTBLANK(1)"));
assert_eq!("1", eval_to_string(g, "COUNTBLANK({\"\", \"a\"; 0, 1})"));
assert_eq!("1", eval_to_string(g, "COUNTBLANK(B3)"));
assert_eq!("28", eval_to_string(g, "COUNTBLANK(B3:C16)"));
assert_eq!("3", eval_to_string(g, "COUNTBLANK({B3, \"\", C6, \"0\"})"));
}

#[test]
Expand Down
2 changes: 1 addition & 1 deletion quadratic-core/src/formulas/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ pub use grid_proxy::GridProxy;
use params::{Param, ParamKind};
pub use parser::{find_cell_references, parse_formula};
pub use span::{Span, Spanned};
pub use values::{Array, BasicValue, CoerceInto, Value};
pub use values::{Array, BasicValue, CoerceInto, IsBlank, Value};
use wildcards::wildcard_pattern_to_regex;

/// Result of a `FormulaError`.
Expand Down
73 changes: 42 additions & 31 deletions quadratic-core/src/formulas/values.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,27 +73,6 @@ impl Value {
}
}

/// Returns whether the value is blank. The empty string is considered
/// non-blank.
pub fn is_blank(&self) -> bool {
match self {
Value::Single(v) => v.is_blank(),
Value::Array(a) => a.values.iter().all(|v| v.is_blank()),
}
}

/// Coerces the value to a specific type; returns `None` if the conversion
/// fails or the original value is `None`.
pub fn coerce_nonblank<'a, T>(&'a self) -> Option<T>
where
&'a Value: TryInto<T>,
{
match self.is_blank() {
true => None,
false => self.try_into().ok(),
}
}

/// Replaces NaN and Inf with errors; otherwise returns the value
/// unchanged.
pub fn purify_floats(self, span: Span) -> FormulaResult<Self> {
Expand Down Expand Up @@ -533,11 +512,6 @@ impl BasicValue {
}
}

/// Returns whether the value is a blank value. The empty string is considered
/// non-blank.
pub fn is_blank(&self) -> bool {
matches!(self, BasicValue::Blank)
}
pub fn is_blank_or_empty_string(&self) -> bool {
self.is_blank() || *self == BasicValue::String(String::new())
}
Expand Down Expand Up @@ -867,6 +841,7 @@ impl TryFrom<Value> for BasicValue {
pub trait CoerceInto: Sized + Unspan
where
for<'a> &'a Self: Into<Span>,
Self::Unspanned: IsBlank,
{
fn into_non_error_value(self) -> FormulaResult<Self::Unspanned>;

Expand All @@ -890,12 +865,10 @@ where
let span = (&self).into();

match self.into_non_error_value() {
// Propagate errors.
Err(e) => Some(Err(e)),
Ok(value) => match value.try_into().with_span(span) {
Ok(result) => Some(Ok(result)),
// If coercion fails, return `None`.
Err(_) => None,
},
// If coercion fails, return `None`.
Ok(value) => value.coerce_nonblank().map(|v| Ok(v).with_span(span)),
}
}
}
Expand Down Expand Up @@ -933,3 +906,41 @@ impl CoerceInto for Spanned<Value> {
}
}
}

pub trait IsBlank {
/// Returns whether the value is blank. The empty string is considered
/// non-blank.
fn is_blank(&self) -> bool;

/// Coerces the value to a specific type; returns `None` if the conversion
/// fails or the original value is blank.
fn coerce_nonblank<'a, T>(self) -> Option<T>
where
Self: TryInto<T>,
{
match self.is_blank() {
true => None,
false => self.try_into().ok(),
}
}
}
impl<T: IsBlank> IsBlank for &'_ T {
fn is_blank(&self) -> bool {
(*self).is_blank()
}
}

impl IsBlank for Value {
fn is_blank(&self) -> bool {
match self {
Value::Single(v) => v.is_blank(),
Value::Array(a) => a.values.iter().all(|v| v.is_blank()),
}
}
}

impl IsBlank for BasicValue {
fn is_blank(&self) -> bool {
matches!(self, BasicValue::Blank)
}
}
90 changes: 90 additions & 0 deletions src/ui/menus/BottomBar/ActiveSelectionStats.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { useState, useEffect } from 'react';
import { runFormula } from '../../../grid/computations/formulas/runFormula';
import { getColumnA1Notation, getRowA1Notation } from '../../../gridGL/UI/gridHeadings/getA1Notation';
import { GridInteractionState } from '../../../atoms/gridInteractionStateAtom';
import { useMediaQuery } from '@mui/material';

interface Props {
interactionState: GridInteractionState;
}

const SELECTION_SIZE_LIMIT = 250;

export const ActiveSelectionStats = (props: Props) => {
const {
showMultiCursor,
multiCursorPosition: { originPosition, terminalPosition },
} = props.interactionState;

const isBigEnoughForActiveSelectionStats = useMediaQuery('(min-width:1000px)');
const [countA, setCountA] = useState<string>('');
const [sum, setSum] = useState<string>('');
const [avg, setAvg] = useState<string>('');

// Run async calculations to get the count/avg/sum meta info
useEffect(() => {
if (showMultiCursor) {
const runCalculationOnActiveSelection = async () => {
const width = Math.abs(originPosition.x - terminalPosition.x) + 1;
const height = Math.abs(originPosition.y - terminalPosition.y) + 1;
const totalArea = width * height;
if (totalArea > SELECTION_SIZE_LIMIT) {
setCountA('');
setAvg('');
setSum('');
return;
}

// Get values around current selection
const colStart = getColumnA1Notation(originPosition.x);
const rowStart = getRowA1Notation(originPosition.y);
const colEnd = getColumnA1Notation(terminalPosition.x);
const rowEnd = getRowA1Notation(terminalPosition.y);
const range = `${colStart}${rowStart}:${colEnd}${rowEnd}`;
const pos = { x: originPosition.x - 1, y: originPosition.y - 1 };

// Get the number of cells with at least some data
const countAReturn = await runFormula(`COUNTA(${range})`, pos);
const countA = countAReturn.success ? Number(countAReturn.output_value) : 0;
setCountA(countA.toString());

// If we don't have at least 2 cells with data and one
// of those is a number, don't bother with sum and avg
const countReturn = await runFormula(`COUNT(${range})`, pos);
const countCellsWithNumbers = countReturn.success ? Number(countReturn.output_value) : 0;
if (countA < 2 || countCellsWithNumbers < 1) {
setAvg('');
setSum('');
return;
}

// Run the formulas
const sumReturn = await runFormula(`SUM(${range})`, pos);
if (sumReturn.success && sumReturn.output_value) {
setSum(sumReturn.output_value);
} else {
console.error('Error running `sum` formula: ', sumReturn.error_msg);
setSum('');
}
const avgReturn = await runFormula(`AVERAGE(${range})`, pos);
if (avgReturn.success && avgReturn.output_value) {
setAvg(avgReturn.output_value);
} else {
console.error('Error running `avg` formula: ', avgReturn.error_msg);
setAvg('');
}
};
runCalculationOnActiveSelection();
}
}, [originPosition, showMultiCursor, terminalPosition]);

if (isBigEnoughForActiveSelectionStats && showMultiCursor)
return (
<>
{sum && <span>Sum: {sum}</span>}
{avg && <span>Avg: {avg}</span>}
{countA && <span>Count: {countA}</span>}
</>
);
else return null;
};
Loading