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:
@ -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)]
|
||||
|
||||
@ -117,6 +117,7 @@ pub fn format_md(model: &Model) -> String {
|
||||
Some(sel) => writeln!(out, "{}: page, {}", cat, sel).unwrap(),
|
||||
None => writeln!(out, "{}: page", cat).unwrap(),
|
||||
},
|
||||
Axis::None => writeln!(out, "{}: none", cat).unwrap(),
|
||||
}
|
||||
}
|
||||
if !view.number_format.is_empty() {
|
||||
@ -315,6 +316,7 @@ pub fn parse_md(text: &str) -> Result<Model> {
|
||||
let axis = match rest {
|
||||
"row" => Axis::Row,
|
||||
"column" => Axis::Column,
|
||||
"none" => Axis::None,
|
||||
_ => continue,
|
||||
};
|
||||
view.axes.push((cat.to_string(), axis));
|
||||
|
||||
@ -402,7 +402,8 @@ impl App {
|
||||
// yy = yank current cell
|
||||
('y', KeyCode::Char('y')) => {
|
||||
if let Some(key) = self.selected_cell_key() {
|
||||
self.yanked = self.model.evaluate(&key);
|
||||
let layout = GridLayout::new(&self.model, self.model.active_view());
|
||||
self.yanked = self.model.evaluate_aggregated(&key, &layout.none_cats);
|
||||
self.status_msg = "Yanked".to_string();
|
||||
}
|
||||
}
|
||||
@ -1111,6 +1112,19 @@ impl App {
|
||||
}
|
||||
self.mode = AppMode::Normal;
|
||||
}
|
||||
KeyCode::Char('n') => {
|
||||
if let Some(name) = cat_names.get(cat_idx) {
|
||||
command::dispatch(
|
||||
&mut self.model,
|
||||
&Command::SetAxis {
|
||||
category: name.clone(),
|
||||
axis: Axis::None,
|
||||
},
|
||||
);
|
||||
self.dirty = true;
|
||||
}
|
||||
self.mode = AppMode::Normal;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
@ -1386,7 +1400,7 @@ impl App {
|
||||
Some(k) => k,
|
||||
None => return false,
|
||||
};
|
||||
let s = match self.model.evaluate(&key) {
|
||||
let s = match self.model.evaluate_aggregated(&key, &layout.none_cats) {
|
||||
Some(CellValue::Number(n)) => format!("{n}"),
|
||||
Some(CellValue::Text(t)) => t,
|
||||
None => String::new(),
|
||||
@ -1608,7 +1622,7 @@ impl App {
|
||||
AppMode::CategoryAdd { .. } => "Enter:add & continue Tab:same Esc:done — type a category name",
|
||||
AppMode::ItemAdd { .. } => "Enter:add & continue Tab:same Esc:done — type an item name",
|
||||
AppMode::ViewPanel => "jk:nav Enter:switch n:new d:delete Esc:back",
|
||||
AppMode::TileSelect { .. } => "hl:select Enter:cycle r/c/p:set-axis Esc:back",
|
||||
AppMode::TileSelect { .. } => "hl:select Enter:cycle r/c/p/n:set-axis Esc:back",
|
||||
AppMode::CommandMode { .. } => ":q quit :w save :import :add-cat :formula :show-item :help",
|
||||
AppMode::ImportWizard => "Space:toggle c:cycle Enter:next Esc:cancel",
|
||||
_ => "",
|
||||
|
||||
@ -14,6 +14,7 @@ fn axis_display(axis: Axis) -> (&'static str, Color) {
|
||||
Axis::Row => ("Row ↕", Color::Green),
|
||||
Axis::Column => ("Col ↔", Color::Blue),
|
||||
Axis::Page => ("Page ☰", Color::Magenta),
|
||||
Axis::None => ("None ∅", Color::DarkGray),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -291,7 +291,7 @@ impl<'a> GridWidget<'a> {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let value = self.model.evaluate(&key);
|
||||
let value = self.model.evaluate_aggregated(&key, &layout.none_cats);
|
||||
|
||||
let cell_str = format_value(value.as_ref(), fmt_comma, fmt_decimals);
|
||||
let is_selected = ri == sel_row && ci == sel_col;
|
||||
@ -378,7 +378,7 @@ impl<'a> GridWidget<'a> {
|
||||
}
|
||||
let total: f64 = (0..layout.row_count())
|
||||
.filter_map(|ri| layout.cell_key(ri, ci))
|
||||
.map(|key| self.model.evaluate_f64(&key))
|
||||
.map(|key| self.model.evaluate_aggregated_f64(&key, &layout.none_cats))
|
||||
.sum();
|
||||
let total_str = format_f64(total, fmt_comma, fmt_decimals);
|
||||
buf.set_string(
|
||||
|
||||
@ -14,6 +14,7 @@ fn axis_display(axis: Axis) -> (&'static str, Color) {
|
||||
Axis::Row => ("↕", Color::Green),
|
||||
Axis::Column => ("↔", Color::Blue),
|
||||
Axis::Page => ("☰", Color::Magenta),
|
||||
Axis::None => ("∅", Color::DarkGray),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ pub enum Axis {
|
||||
Row,
|
||||
Column,
|
||||
Page,
|
||||
None,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Axis {
|
||||
@ -14,6 +15,7 @@ impl std::fmt::Display for Axis {
|
||||
Axis::Row => write!(f, "Row ↕"),
|
||||
Axis::Column => write!(f, "Col ↔"),
|
||||
Axis::Page => write!(f, "Page ☰"),
|
||||
Axis::None => write!(f, "None ∅"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,6 +27,8 @@ pub struct GridLayout {
|
||||
pub page_coords: Vec<(String, String)>,
|
||||
pub row_items: Vec<AxisEntry>,
|
||||
pub col_items: Vec<AxisEntry>,
|
||||
/// Categories on `Axis::None` — hidden, implicitly aggregated.
|
||||
pub none_cats: Vec<String>,
|
||||
}
|
||||
|
||||
impl GridLayout {
|
||||
@ -46,6 +48,11 @@ impl GridLayout {
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
let none_cats: Vec<String> = view
|
||||
.categories_on(Axis::None)
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
|
||||
let page_coords = page_cats
|
||||
.iter()
|
||||
@ -77,6 +84,7 @@ impl GridLayout {
|
||||
page_coords,
|
||||
row_items,
|
||||
col_items,
|
||||
none_cats,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -148,12 +148,13 @@ impl View {
|
||||
self.col_offset = 0;
|
||||
}
|
||||
|
||||
/// Cycle axis for a category: Row → Column → Page → Row
|
||||
/// Cycle axis for a category: Row → Column → Page → None → Row
|
||||
pub fn cycle_axis(&mut self, cat_name: &str) {
|
||||
let next = match self.axis_of(cat_name) {
|
||||
Axis::Row => Axis::Column,
|
||||
Axis::Column => Axis::Page,
|
||||
Axis::Page => Axis::Row,
|
||||
Axis::Page => Axis::None,
|
||||
Axis::None => Axis::Row,
|
||||
};
|
||||
self.set_axis(cat_name, next);
|
||||
self.selected = (0, 0);
|
||||
@ -302,9 +303,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_axis_page_to_row() {
|
||||
fn cycle_axis_page_to_none() {
|
||||
let mut v = view_with_cats(&["Region", "Product", "Time"]);
|
||||
v.cycle_axis("Time");
|
||||
assert_eq!(v.axis_of("Time"), Axis::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_axis_none_to_row() {
|
||||
let mut v = view_with_cats(&["Region", "Product", "Time"]);
|
||||
v.set_axis("Time", Axis::None);
|
||||
v.cycle_axis("Time");
|
||||
assert_eq!(v.axis_of("Time"), Axis::Row);
|
||||
}
|
||||
|
||||
@ -351,7 +360,7 @@ mod prop_tests {
|
||||
fn each_category_on_exactly_one_axis(cats in unique_cat_names()) {
|
||||
let mut v = View::new("T");
|
||||
for c in &cats { v.on_category_added(c); }
|
||||
let all_axes = [Axis::Row, Axis::Column, Axis::Page];
|
||||
let all_axes = [Axis::Row, Axis::Column, Axis::Page, Axis::None];
|
||||
for c in &cats {
|
||||
let count = all_axes.iter()
|
||||
.filter(|&&ax| v.categories_on(ax).contains(&c.as_str()))
|
||||
@ -377,7 +386,7 @@ mod prop_tests {
|
||||
fn set_axis_updates_axis_of(
|
||||
cats in unique_cat_names(),
|
||||
target_idx in 0usize..8,
|
||||
axis in prop_oneof![Just(Axis::Row), Just(Axis::Column), Just(Axis::Page)],
|
||||
axis in prop_oneof![Just(Axis::Row), Just(Axis::Column), Just(Axis::Page), Just(Axis::None)],
|
||||
) {
|
||||
let mut v = View::new("T");
|
||||
for c in &cats { v.on_category_added(c); }
|
||||
@ -392,7 +401,7 @@ mod prop_tests {
|
||||
fn set_axis_exclusive(
|
||||
cats in unique_cat_names(),
|
||||
target_idx in 0usize..8,
|
||||
axis in prop_oneof![Just(Axis::Row), Just(Axis::Column), Just(Axis::Page)],
|
||||
axis in prop_oneof![Just(Axis::Row), Just(Axis::Column), Just(Axis::Page), Just(Axis::None)],
|
||||
) {
|
||||
let mut v = View::new("T");
|
||||
for c in &cats { v.on_category_added(c); }
|
||||
|
||||
Reference in New Issue
Block a user