feat: add prune empty feature for pivot views
Add prune_empty feature to hide empty rows/columns in pivot mode. View gains prune_empty boolean (default false for backward compat). GridLayout::prune_empty() removes data rows where all columns are empty and data columns where all rows are empty. Group headers are preserved if at least one data item survives. In records mode, pruning is skipped (user drilled in to see all data). EditOrDrill command updated to check for regular (non-virtual) categories when determining if a cell is aggregated. Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
This commit is contained in:
@ -53,6 +53,9 @@ impl GridLayout {
|
||||
layout.records = Some(records);
|
||||
}
|
||||
}
|
||||
if view.prune_empty {
|
||||
layout.prune_empty(model);
|
||||
}
|
||||
layout
|
||||
}
|
||||
|
||||
@ -152,10 +155,11 @@ impl GridLayout {
|
||||
.map(|i| AxisEntry::DataItem(vec![i.to_string()]))
|
||||
.collect();
|
||||
|
||||
// Synthesize col items: one per category + "Value"
|
||||
// Synthesize col items: one per non-virtual category + "Value"
|
||||
let cat_names: Vec<String> = model
|
||||
.category_names()
|
||||
.into_iter()
|
||||
.filter(|c| !c.starts_with('_'))
|
||||
.map(String::from)
|
||||
.collect();
|
||||
let mut col_items: Vec<AxisEntry> = cat_names
|
||||
@ -195,6 +199,108 @@ impl GridLayout {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove data rows where every column is empty and data columns
|
||||
/// where every row is empty. Group headers are kept if at least one
|
||||
/// of their data items survives.
|
||||
///
|
||||
/// In records mode every column is shown (the user drilled in to see
|
||||
/// all the raw data). In pivot mode, rows and columns where every
|
||||
/// cell is empty are hidden to reduce clutter.
|
||||
pub fn prune_empty(&mut self, model: &Model) {
|
||||
if self.is_records_mode() {
|
||||
return;
|
||||
}
|
||||
let rc = self.row_count();
|
||||
let cc = self.col_count();
|
||||
if rc == 0 || cc == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build a row×col grid of "has content?"
|
||||
let mut has_value = vec![vec![false; cc]; rc];
|
||||
for ri in 0..rc {
|
||||
for ci in 0..cc {
|
||||
has_value[ri][ci] = if self.is_records_mode() {
|
||||
let s = self.records_display(ri, ci).unwrap_or_default();
|
||||
!s.is_empty()
|
||||
} else {
|
||||
self.cell_key(ri, ci)
|
||||
.and_then(|k| model.evaluate_aggregated(&k, &self.none_cats))
|
||||
.is_some()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Which data-row indices are non-empty?
|
||||
let keep_row: Vec<bool> = (0..rc)
|
||||
.map(|ri| (0..cc).any(|ci| has_value[ri][ci]))
|
||||
.collect();
|
||||
// Which data-col indices are non-empty?
|
||||
let keep_col: Vec<bool> = (0..cc)
|
||||
.map(|ci| (0..rc).any(|ri| has_value[ri][ci]))
|
||||
.collect();
|
||||
|
||||
// Filter row_items, preserving group headers when at least one
|
||||
// subsequent data item survives.
|
||||
let mut new_rows = Vec::new();
|
||||
let mut pending_header: Option<AxisEntry> = None;
|
||||
let mut data_idx = 0usize;
|
||||
for entry in self.row_items.drain(..) {
|
||||
match &entry {
|
||||
AxisEntry::GroupHeader { .. } => {
|
||||
pending_header = Some(entry);
|
||||
}
|
||||
AxisEntry::DataItem(_) => {
|
||||
if data_idx < rc && keep_row[data_idx] {
|
||||
if let Some(h) = pending_header.take() {
|
||||
new_rows.push(h);
|
||||
}
|
||||
new_rows.push(entry);
|
||||
}
|
||||
data_idx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.row_items = new_rows;
|
||||
|
||||
// Filter col_items (same logic)
|
||||
let mut new_cols = Vec::new();
|
||||
let mut pending_header: Option<AxisEntry> = None;
|
||||
let mut data_idx = 0usize;
|
||||
for entry in self.col_items.drain(..) {
|
||||
match &entry {
|
||||
AxisEntry::GroupHeader { .. } => {
|
||||
pending_header = Some(entry);
|
||||
}
|
||||
AxisEntry::DataItem(_) => {
|
||||
if data_idx < cc && keep_col[data_idx] {
|
||||
if let Some(h) = pending_header.take() {
|
||||
new_cols.push(h);
|
||||
}
|
||||
new_cols.push(entry);
|
||||
}
|
||||
data_idx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.col_items = new_cols;
|
||||
|
||||
// If records mode, also prune the records vec and re-index row_items
|
||||
if let Some(records) = &self.records {
|
||||
let new_records: Vec<_> = keep_row
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, keep)| **keep)
|
||||
.map(|(i, _)| records[i].clone())
|
||||
.collect();
|
||||
let new_row_items: Vec<AxisEntry> = (0..new_records.len())
|
||||
.map(|i| AxisEntry::DataItem(vec![i.to_string()]))
|
||||
.collect();
|
||||
self.row_items = new_row_items;
|
||||
self.records = Some(new_records);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this layout is in records mode.
|
||||
pub fn is_records_mode(&self) -> bool {
|
||||
self.records.is_some()
|
||||
@ -450,6 +556,30 @@ mod tests {
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_empty_removes_all_empty_columns_in_pivot_mode() {
|
||||
let mut m = Model::new("T");
|
||||
m.add_category("Row").unwrap();
|
||||
m.add_category("Col").unwrap();
|
||||
m.category_mut("Row").unwrap().add_item("A");
|
||||
m.category_mut("Col").unwrap().add_item("X");
|
||||
m.category_mut("Col").unwrap().add_item("Y");
|
||||
// Only X has data; Y is entirely empty
|
||||
m.set_cell(
|
||||
CellKey::new(vec![
|
||||
("Row".into(), "A".into()),
|
||||
("Col".into(), "X".into()),
|
||||
]),
|
||||
CellValue::Number(1.0),
|
||||
);
|
||||
|
||||
let mut layout = GridLayout::new(&m, m.active_view());
|
||||
assert_eq!(layout.col_count(), 2); // X and Y before pruning
|
||||
layout.prune_empty(&m);
|
||||
assert_eq!(layout.col_count(), 1); // only X after pruning
|
||||
assert_eq!(layout.col_label(0), "X");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_mode_activated_when_index_and_dim_on_axes() {
|
||||
let mut m = records_model();
|
||||
|
||||
Reference in New Issue
Block a user