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)]

View File

@ -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));

View File

@ -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",
_ => "",

View File

@ -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),
}
}

View File

@ -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(

View File

@ -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),
}
}

View File

@ -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 ∅"),
}
}
}

View File

@ -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,
}
}

View File

@ -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); }