feat: add Axis::None for hidden dimensions with implicit aggregation

Categories on the None axis are excluded from the grid and cell keys.
When evaluating cells, values across hidden dimensions are aggregated
using a per-measure function (default SUM). Adds evaluate_aggregated
to Model, none_cats to GridLayout, and 'n' shortcut in TileSelect.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Edward Langley
2026-04-02 16:38:35 -07:00
parent dd728ccac8
commit 5a251a1cbe
9 changed files with 193 additions and 12 deletions

View File

@ -1,10 +1,12 @@
use std::collections::HashMap;
use anyhow::{anyhow, Result};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use super::category::{Category, CategoryId};
use super::cell::{CellKey, CellValue, DataStore};
use crate::formula::Formula;
use crate::formula::{AggFunc, Formula};
use crate::view::View;
const MAX_CATEGORIES: usize = 12;
@ -18,6 +20,10 @@ pub struct Model {
pub views: IndexMap<String, View>,
pub active_view: String,
next_category_id: CategoryId,
/// Per-measure aggregation function (measure item name → agg func).
/// Used when collapsing categories on `Axis::None`. Defaults to SUM.
#[serde(default)]
pub measure_agg: HashMap<String, AggFunc>,
}
impl Model {
@ -34,6 +40,7 @@ impl Model {
views,
active_view: "Default".to_string(),
next_category_id: 0,
measure_agg: HashMap::new(),
}
}
@ -172,6 +179,67 @@ impl Model {
self.evaluate(key).and_then(|v| v.as_f64()).unwrap_or(0.0)
}
/// Evaluate a cell, aggregating over any hidden (None-axis) categories.
/// When `none_cats` is empty, delegates to `evaluate`.
/// Otherwise, uses `matching_cells` with the partial key and aggregates
/// using the measure's agg function (default SUM).
pub fn evaluate_aggregated(
&self,
key: &CellKey,
none_cats: &[String],
) -> Option<CellValue> {
if none_cats.is_empty() {
return self.evaluate(key);
}
// Check formulas first — they handle their own aggregation
for formula in &self.formulas {
if let Some(item_val) = key.get(&formula.target_category) {
if item_val == formula.target {
return self.eval_formula(formula, key);
}
}
}
// Aggregate raw data across all None-axis categories
let values: Vec<f64> = self
.data
.matching_cells(&key.0)
.into_iter()
.filter_map(|(_, v)| v.as_f64())
.collect();
if values.is_empty() {
return None;
}
// Determine agg func from measure_agg map, defaulting to SUM
let agg = key
.get("Measure")
.and_then(|m| self.measure_agg.get(m))
.unwrap_or(&AggFunc::Sum);
let result = match agg {
AggFunc::Sum => values.iter().sum(),
AggFunc::Avg => values.iter().sum::<f64>() / values.len() as f64,
AggFunc::Min => values.iter().cloned().reduce(f64::min)?,
AggFunc::Max => values.iter().cloned().reduce(f64::max)?,
AggFunc::Count => values.len() as f64,
};
Some(CellValue::Number(result))
}
/// Evaluate aggregated as f64, returning 0.0 for empty cells.
pub fn evaluate_aggregated_f64(
&self,
key: &CellKey,
none_cats: &[String],
) -> f64 {
self.evaluate_aggregated(key, none_cats)
.and_then(|v| v.as_f64())
.unwrap_or(0.0)
}
fn eval_formula(&self, formula: &Formula, context: &CellKey) -> Option<CellValue> {
use crate::formula::{AggFunc, Expr};
@ -490,6 +558,82 @@ mod model_tests {
m.set_cell(k.clone(), CellValue::Number(77.0));
assert_eq!(m.get_cell(&k), Some(&CellValue::Number(77.0)));
}
#[test]
fn evaluate_aggregated_sums_over_hidden_dimension() {
let mut m = Model::new("Test");
m.add_category("Payee").unwrap();
m.add_category("Date").unwrap();
m.add_category("Measure").unwrap();
m.category_mut("Payee").unwrap().add_item("Acme");
m.category_mut("Date").unwrap().add_item("Jan-01");
m.category_mut("Date").unwrap().add_item("Jan-02");
m.category_mut("Measure").unwrap().add_item("Amount");
m.set_cell(
coord(&[("Payee", "Acme"), ("Date", "Jan-01"), ("Measure", "Amount")]),
CellValue::Number(100.0),
);
m.set_cell(
coord(&[("Payee", "Acme"), ("Date", "Jan-02"), ("Measure", "Amount")]),
CellValue::Number(50.0),
);
// Without hidden dims, returns None for partial key
let partial_key = coord(&[("Payee", "Acme"), ("Measure", "Amount")]);
assert_eq!(m.evaluate(&partial_key), None);
// With Date as hidden dimension, aggregates to SUM
let none_cats = vec!["Date".to_string()];
let result = m.evaluate_aggregated(&partial_key, &none_cats);
assert_eq!(result, Some(CellValue::Number(150.0)));
}
#[test]
fn evaluate_aggregated_no_hidden_delegates_to_evaluate() {
let mut m = Model::new("Test");
m.add_category("Region").unwrap();
m.category_mut("Region").unwrap().add_item("East");
m.set_cell(
coord(&[("Region", "East")]),
CellValue::Number(42.0),
);
let key = coord(&[("Region", "East")]);
assert_eq!(
m.evaluate_aggregated(&key, &[]),
Some(CellValue::Number(42.0))
);
}
#[test]
fn evaluate_aggregated_respects_measure_agg() {
use crate::formula::AggFunc;
let mut m = Model::new("Test");
m.add_category("Payee").unwrap();
m.add_category("Date").unwrap();
m.add_category("Measure").unwrap();
m.category_mut("Payee").unwrap().add_item("Acme");
m.category_mut("Date").unwrap().add_item("D1");
m.category_mut("Date").unwrap().add_item("D2");
m.category_mut("Measure").unwrap().add_item("Price");
m.set_cell(
coord(&[("Payee", "Acme"), ("Date", "D1"), ("Measure", "Price")]),
CellValue::Number(10.0),
);
m.set_cell(
coord(&[("Payee", "Acme"), ("Date", "D2"), ("Measure", "Price")]),
CellValue::Number(30.0),
);
m.measure_agg.insert("Price".to_string(), AggFunc::Avg);
let key = coord(&[("Payee", "Acme"), ("Measure", "Price")]);
let none_cats = vec!["Date".to_string()];
let result = m.evaluate_aggregated(&key, &none_cats);
assert_eq!(result, Some(CellValue::Number(20.0))); // avg(10, 30) = 20
}
}
#[cfg(test)]