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

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