feat: add records mode (long-format view) for drill-down
Implement records mode (long-format view) when drilling into aggregated cells. Key changes: - DrillIntoCell now creates views with _Index on Row and _Dim on Column - GridLayout detects records mode and builds a records list instead of cross-product row/col items - Added records_display() to render individual cell values in records mode - GridWidget and CSV export updated to handle records mode rendering - category_names() now includes virtual categories (_Index, _Dim) - Tests updated to reflect virtual categories counting toward limits Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M)
This commit is contained in:
@ -1,4 +1,4 @@
|
||||
use crate::model::cell::CellKey;
|
||||
use crate::model::cell::{CellKey, CellValue};
|
||||
use crate::model::Model;
|
||||
use crate::view::{Axis, View};
|
||||
|
||||
@ -29,6 +29,9 @@ pub struct GridLayout {
|
||||
pub col_items: Vec<AxisEntry>,
|
||||
/// Categories on `Axis::None` — hidden, implicitly aggregated.
|
||||
pub none_cats: Vec<String>,
|
||||
/// In records mode: the filtered cell list, one per row.
|
||||
/// None for normal pivot views.
|
||||
pub records: Option<Vec<(CellKey, CellValue)>>,
|
||||
}
|
||||
|
||||
impl GridLayout {
|
||||
@ -75,19 +78,107 @@ impl GridLayout {
|
||||
})
|
||||
.collect();
|
||||
|
||||
let row_items = cross_product(model, view, &row_cats);
|
||||
let col_items = cross_product(model, view, &col_cats);
|
||||
// Detect records mode: _Index on Row and _Dim on Col
|
||||
let is_records_mode =
|
||||
row_cats.iter().any(|c| c == "_Index") && col_cats.iter().any(|c| c == "_Dim");
|
||||
|
||||
if is_records_mode {
|
||||
Self::build_records_mode(model, view, page_coords, none_cats)
|
||||
} else {
|
||||
let row_items = cross_product(model, view, &row_cats);
|
||||
let col_items = cross_product(model, view, &col_cats);
|
||||
Self {
|
||||
row_cats,
|
||||
col_cats,
|
||||
page_coords,
|
||||
row_items,
|
||||
col_items,
|
||||
none_cats,
|
||||
records: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a records-mode layout: rows are individual cells, columns are
|
||||
/// category names + "Value". Cells matching the page filter are enumerated.
|
||||
fn build_records_mode(
|
||||
model: &Model,
|
||||
_view: &View,
|
||||
page_coords: Vec<(String, String)>,
|
||||
none_cats: Vec<String>,
|
||||
) -> Self {
|
||||
// Filter cells by page_coords
|
||||
let partial: Vec<(String, String)> = page_coords.clone();
|
||||
let mut records: Vec<(CellKey, CellValue)> = if partial.is_empty() {
|
||||
model
|
||||
.data
|
||||
.iter_cells()
|
||||
.map(|(k, v)| (k, v.clone()))
|
||||
.collect()
|
||||
} else {
|
||||
model
|
||||
.data
|
||||
.matching_cells(&partial)
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, v.clone()))
|
||||
.collect()
|
||||
};
|
||||
// Sort for deterministic ordering
|
||||
records.sort_by(|a, b| a.0.0.cmp(&b.0.0));
|
||||
|
||||
// Synthesize row items: one per record, labeled with its index
|
||||
let row_items: Vec<AxisEntry> = (0..records.len())
|
||||
.map(|i| AxisEntry::DataItem(vec![i.to_string()]))
|
||||
.collect();
|
||||
|
||||
// Synthesize col items: one per regular category + "Value"
|
||||
let cat_names: Vec<String> = model
|
||||
.category_names()
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
let mut col_items: Vec<AxisEntry> = cat_names
|
||||
.iter()
|
||||
.map(|c| AxisEntry::DataItem(vec![c.clone()]))
|
||||
.collect();
|
||||
col_items.push(AxisEntry::DataItem(vec!["Value".to_string()]));
|
||||
|
||||
Self {
|
||||
row_cats,
|
||||
col_cats,
|
||||
row_cats: vec!["_Index".to_string()],
|
||||
col_cats: vec!["_Dim".to_string()],
|
||||
page_coords,
|
||||
row_items,
|
||||
col_items,
|
||||
none_cats,
|
||||
records: Some(records),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the display string for the cell at (row, col) in records mode.
|
||||
/// Returns None for normal (non-records) layouts.
|
||||
pub fn records_display(&self, row: usize, col: usize) -> Option<String> {
|
||||
let records = self.records.as_ref()?;
|
||||
let record = records.get(row)?;
|
||||
let col_item = self.col_label(col);
|
||||
if col_item == "Value" {
|
||||
Some(record.1.to_string())
|
||||
} else {
|
||||
// col_item is a category name
|
||||
let found = record
|
||||
.0
|
||||
.0
|
||||
.iter()
|
||||
.find(|(c, _)| c == &col_item)
|
||||
.map(|(_, v)| v.clone());
|
||||
Some(found.unwrap_or_default())
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this layout is in records mode.
|
||||
pub fn is_records_mode(&self) -> bool {
|
||||
self.records.is_some()
|
||||
}
|
||||
|
||||
/// Number of data rows (group headers excluded).
|
||||
pub fn row_count(&self) -> usize {
|
||||
self.row_items
|
||||
|
||||
Reference in New Issue
Block a user