feat: add view history navigation and drill-into-cell

Add view navigation history with back/forward stacks (bound to < and >).

Introduce CategoryKind enum to distinguish regular categories from
virtual ones (_Index, _Dim) that are synthesized at query time.

Add DrillIntoCell command that creates a drill view showing raw data
for an aggregated cell, expanding categories on Axis::None into Row
and Column axes while filtering by the cell's fixed coordinates.

Virtual categories default to Axis::None and are automatically added
to all views when the model is initialized.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
This commit is contained in:
Edward Langley
2026-04-05 10:57:28 -07:00
parent b2d633eb7d
commit 67041dd4a5
7 changed files with 253 additions and 16 deletions

View File

@ -28,25 +28,48 @@ pub struct Model {
impl Model {
pub fn new(name: impl Into<String>) -> Self {
use crate::model::category::CategoryKind;
let name = name.into();
let default_view = View::new("Default");
let mut views = IndexMap::new();
views.insert("Default".to_string(), default_view);
Self {
let mut categories = IndexMap::new();
// Virtual categories — always present, default to Axis::None
categories.insert(
"_Index".to_string(),
Category::new(0, "_Index").with_kind(CategoryKind::VirtualIndex),
);
categories.insert(
"_Dim".to_string(),
Category::new(1, "_Dim").with_kind(CategoryKind::VirtualDim),
);
let mut m = Self {
name,
categories: IndexMap::new(),
categories,
data: DataStore::new(),
formulas: Vec::new(),
views,
active_view: "Default".to_string(),
next_category_id: 0,
next_category_id: 2,
measure_agg: HashMap::new(),
};
// Add virtuals to existing views (default view)
for view in m.views.values_mut() {
view.on_category_added("_Index");
view.on_category_added("_Dim");
}
m
}
pub fn add_category(&mut self, name: impl Into<String>) -> Result<CategoryId> {
let name = name.into();
if self.categories.len() >= MAX_CATEGORIES {
// Count only regular categories for the limit
let regular_count = self
.categories
.values()
.filter(|c| !c.kind.is_virtual())
.count();
if regular_count >= MAX_CATEGORIES {
return Err(anyhow!("Maximum of {MAX_CATEGORIES} categories reached"));
}
if self.categories.contains_key(&name) {
@ -157,7 +180,17 @@ impl Model {
}
/// Return all category names
/// Names of all regular (non-virtual) categories.
pub fn category_names(&self) -> Vec<&str> {
self.categories
.iter()
.filter(|(_, c)| !c.kind.is_virtual())
.map(|(s, _)| s.as_str())
.collect()
}
/// Names of all categories including virtual ones.
pub fn all_category_names(&self) -> Vec<&str> {
self.categories.keys().map(|s| s.as_str()).collect()
}
@ -399,7 +432,7 @@ mod model_tests {
let id1 = m.add_category("Region").unwrap();
let id2 = m.add_category("Region").unwrap();
assert_eq!(id1, id2);
assert_eq!(m.categories.len(), 1);
assert_eq!(m.category_names().len(), 1);
}
#[test]
@ -1367,12 +1400,12 @@ mod five_category {
#[test]
fn five_categories_well_within_limit() {
let m = build_model();
assert_eq!(m.categories.len(), 5);
assert_eq!(m.category_names().len(), 5);
let mut m2 = build_model();
for i in 0..7 {
m2.add_category(format!("Extra{i}")).unwrap();
}
assert_eq!(m2.categories.len(), 12);
assert_eq!(m2.category_names().len(), 12);
assert!(m2.add_category("OneMore").is_err());
}
}