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:
@ -1005,10 +1005,9 @@ impl Cmd for ViewForwardCmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drill down into an aggregated cell: create a _Drill view that shows the
|
/// Drill down into an aggregated cell: create a _Drill view with _Index on
|
||||||
/// raw (un-aggregated) data for this cell. Categories on Axis::None in the
|
/// Row and _Dim on Column (records/long-format view). Fixed coordinates
|
||||||
/// current view become visible (Row + Column) in the drill view; the cell's
|
/// from the drilled cell become page filters.
|
||||||
/// fixed coordinates become page filters.
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct DrillIntoCell {
|
pub struct DrillIntoCell {
|
||||||
pub key: crate::model::cell::CellKey,
|
pub key: crate::model::cell::CellKey,
|
||||||
@ -1025,46 +1024,40 @@ impl Cmd for DrillIntoCell {
|
|||||||
effects.push(Box::new(effect::CreateView(drill_name.clone())));
|
effects.push(Box::new(effect::CreateView(drill_name.clone())));
|
||||||
effects.push(Box::new(effect::SwitchView(drill_name)));
|
effects.push(Box::new(effect::SwitchView(drill_name)));
|
||||||
|
|
||||||
// All categories currently exist. Set axes:
|
// Records mode: _Index on Row, _Dim on Column
|
||||||
// - none_cats → Row (first) and Column (rest) to expand them
|
effects.push(Box::new(effect::SetAxis {
|
||||||
// - cell_key cats → Page with their specific items (filter)
|
category: "_Index".to_string(),
|
||||||
// - other cats (not in cell_key or none_cats) → Page as well
|
axis: crate::view::Axis::Row,
|
||||||
let none_cats = &ctx.none_cats;
|
}));
|
||||||
|
effects.push(Box::new(effect::SetAxis {
|
||||||
|
category: "_Dim".to_string(),
|
||||||
|
axis: crate::view::Axis::Column,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Fixed coords (from drilled cell) → Page with that value as filter
|
||||||
let fixed_cats: std::collections::HashSet<String> =
|
let fixed_cats: std::collections::HashSet<String> =
|
||||||
self.key.0.iter().map(|(c, _)| c.clone()).collect();
|
self.key.0.iter().map(|(c, _)| c.clone()).collect();
|
||||||
|
for (cat, item) in &self.key.0 {
|
||||||
for (i, cat) in none_cats.iter().enumerate() {
|
|
||||||
let axis = if i == 0 {
|
|
||||||
crate::view::Axis::Row
|
|
||||||
} else {
|
|
||||||
crate::view::Axis::Column
|
|
||||||
};
|
|
||||||
effects.push(Box::new(effect::SetAxis {
|
|
||||||
category: cat.clone(),
|
|
||||||
axis,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// All other categories → Page, with the cell's value as the page selection
|
|
||||||
for cat_name in ctx.model.category_names() {
|
|
||||||
let cat = cat_name.to_string();
|
|
||||||
if none_cats.contains(&cat) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
effects.push(Box::new(effect::SetAxis {
|
effects.push(Box::new(effect::SetAxis {
|
||||||
category: cat.clone(),
|
category: cat.clone(),
|
||||||
axis: crate::view::Axis::Page,
|
axis: crate::view::Axis::Page,
|
||||||
}));
|
}));
|
||||||
// If this category was in the drilled cell's key, fix its page
|
|
||||||
// selection to the cell's value
|
|
||||||
if fixed_cats.contains(&cat) {
|
|
||||||
if let Some((_, item)) = self.key.0.iter().find(|(c, _)| c == &cat) {
|
|
||||||
effects.push(Box::new(effect::SetPageSelection {
|
effects.push(Box::new(effect::SetPageSelection {
|
||||||
category: cat,
|
category: cat.clone(),
|
||||||
item: item.clone(),
|
item: item.clone(),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
// Previously-aggregated categories (none_cats) stay on Axis::None so
|
||||||
|
// they don't filter records; they'll appear as columns in records mode.
|
||||||
|
// Skip virtual categories — we already set _Index/_Dim above.
|
||||||
|
for cat in &ctx.none_cats {
|
||||||
|
if fixed_cats.contains(cat) || cat.starts_with('_') {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
effects.push(Box::new(effect::SetAxis {
|
||||||
|
category: cat.clone(),
|
||||||
|
axis: crate::view::Axis::None,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
effects.push(effect::set_status("Drilled into cell"));
|
effects.push(effect::set_status("Drilled into cell"));
|
||||||
@ -2782,11 +2775,13 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn enter_tile_select_no_categories() {
|
fn enter_tile_select_no_categories() {
|
||||||
|
// Models always have virtual categories (_Index, _Dim), so tile
|
||||||
|
// select always has something to operate on.
|
||||||
let m = Model::new("Empty");
|
let m = Model::new("Empty");
|
||||||
let ctx = make_ctx(&m);
|
let ctx = make_ctx(&m);
|
||||||
let cmd = EnterTileSelect;
|
let cmd = EnterTileSelect;
|
||||||
let effects = cmd.execute(&ctx);
|
let effects = cmd.execute(&ctx);
|
||||||
assert!(effects.is_empty());
|
assert_eq!(effects.len(), 2); // SetTileCatIdx + ChangeMode
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -63,7 +63,7 @@ impl Model {
|
|||||||
|
|
||||||
pub fn add_category(&mut self, name: impl Into<String>) -> Result<CategoryId> {
|
pub fn add_category(&mut self, name: impl Into<String>) -> Result<CategoryId> {
|
||||||
let name = name.into();
|
let name = name.into();
|
||||||
// Count only regular categories for the limit
|
// Virtuals don't count against the regular category limit
|
||||||
let regular_count = self
|
let regular_count = self
|
||||||
.categories
|
.categories
|
||||||
.values()
|
.values()
|
||||||
@ -180,17 +180,8 @@ impl Model {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Return all category names
|
/// Return all category names
|
||||||
/// Names of all regular (non-virtual) categories.
|
/// Names of all categories (including virtual ones).
|
||||||
pub fn category_names(&self) -> Vec<&str> {
|
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()
|
self.categories.keys().map(|s| s.as_str()).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -432,7 +423,8 @@ mod model_tests {
|
|||||||
let id1 = m.add_category("Region").unwrap();
|
let id1 = m.add_category("Region").unwrap();
|
||||||
let id2 = m.add_category("Region").unwrap();
|
let id2 = m.add_category("Region").unwrap();
|
||||||
assert_eq!(id1, id2);
|
assert_eq!(id1, id2);
|
||||||
assert_eq!(m.category_names().len(), 1);
|
// Region + 2 virtuals (_Index, _Dim)
|
||||||
|
assert_eq!(m.category_names().len(), 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -1400,12 +1392,14 @@ mod five_category {
|
|||||||
#[test]
|
#[test]
|
||||||
fn five_categories_well_within_limit() {
|
fn five_categories_well_within_limit() {
|
||||||
let m = build_model();
|
let m = build_model();
|
||||||
assert_eq!(m.category_names().len(), 5);
|
// 5 regular + 2 virtual (_Index, _Dim)
|
||||||
|
assert_eq!(m.category_names().len(), 7);
|
||||||
let mut m2 = build_model();
|
let mut m2 = build_model();
|
||||||
for i in 0..7 {
|
for i in 0..7 {
|
||||||
m2.add_category(format!("Extra{i}")).unwrap();
|
m2.add_category(format!("Extra{i}")).unwrap();
|
||||||
}
|
}
|
||||||
assert_eq!(m2.category_names().len(), 12);
|
// 12 regular + 2 virtuals = 14
|
||||||
|
assert_eq!(m2.category_names().len(), 14);
|
||||||
assert!(m2.add_category("OneMore").is_err());
|
assert!(m2.add_category("OneMore").is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -459,11 +459,15 @@ pub fn export_csv(model: &Model, view_name: &str, path: &Path) -> Result<()> {
|
|||||||
}
|
}
|
||||||
let row_values: Vec<String> = (0..layout.col_count())
|
let row_values: Vec<String> = (0..layout.col_count())
|
||||||
.map(|ci| {
|
.map(|ci| {
|
||||||
|
if layout.is_records_mode() {
|
||||||
|
layout.records_display(ri, ci).unwrap_or_default()
|
||||||
|
} else {
|
||||||
layout
|
layout
|
||||||
.cell_key(ri, ci)
|
.cell_key(ri, ci)
|
||||||
.and_then(|key| model.evaluate_aggregated(&key, &layout.none_cats))
|
.and_then(|key| model.evaluate_aggregated(&key, &layout.none_cats))
|
||||||
.map(|v| v.to_string())
|
.map(|v| v.to_string())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
out.push_str(&row_values.join(","));
|
out.push_str(&row_values.join(","));
|
||||||
|
|||||||
@ -291,6 +291,16 @@ impl<'a> GridWidget<'a> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let (cell_str, value) = if layout.is_records_mode() {
|
||||||
|
let s = layout.records_display(ri, ci).unwrap_or_default();
|
||||||
|
// In records mode the value is a string, not aggregated
|
||||||
|
let v = if !s.is_empty() {
|
||||||
|
Some(crate::model::cell::CellValue::Text(s.clone()))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
(s, v)
|
||||||
|
} else {
|
||||||
let key = match layout.cell_key(ri, ci) {
|
let key = match layout.cell_key(ri, ci) {
|
||||||
Some(k) => k,
|
Some(k) => k,
|
||||||
None => {
|
None => {
|
||||||
@ -299,8 +309,9 @@ impl<'a> GridWidget<'a> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let value = self.model.evaluate_aggregated(&key, &layout.none_cats);
|
let value = self.model.evaluate_aggregated(&key, &layout.none_cats);
|
||||||
|
let s = format_value(value.as_ref(), fmt_comma, fmt_decimals);
|
||||||
let cell_str = format_value(value.as_ref(), fmt_comma, fmt_decimals);
|
(s, value)
|
||||||
|
};
|
||||||
let is_selected = ri == sel_row && ci == sel_col;
|
let is_selected = ri == sel_row && ci == sel_col;
|
||||||
let is_search_match = !self.search_query.is_empty()
|
let is_search_match = !self.search_query.is_empty()
|
||||||
&& cell_str
|
&& cell_str
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
use crate::model::cell::CellKey;
|
use crate::model::cell::{CellKey, CellValue};
|
||||||
use crate::model::Model;
|
use crate::model::Model;
|
||||||
use crate::view::{Axis, View};
|
use crate::view::{Axis, View};
|
||||||
|
|
||||||
@ -29,6 +29,9 @@ pub struct GridLayout {
|
|||||||
pub col_items: Vec<AxisEntry>,
|
pub col_items: Vec<AxisEntry>,
|
||||||
/// Categories on `Axis::None` — hidden, implicitly aggregated.
|
/// Categories on `Axis::None` — hidden, implicitly aggregated.
|
||||||
pub none_cats: Vec<String>,
|
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 {
|
impl GridLayout {
|
||||||
@ -75,9 +78,15 @@ impl GridLayout {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
// 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 row_items = cross_product(model, view, &row_cats);
|
||||||
let col_items = cross_product(model, view, &col_cats);
|
let col_items = cross_product(model, view, &col_cats);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
row_cats,
|
row_cats,
|
||||||
col_cats,
|
col_cats,
|
||||||
@ -85,8 +94,90 @@ impl GridLayout {
|
|||||||
row_items,
|
row_items,
|
||||||
col_items,
|
col_items,
|
||||||
none_cats,
|
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: 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).
|
/// Number of data rows (group headers excluded).
|
||||||
pub fn row_count(&self) -> usize {
|
pub fn row_count(&self) -> usize {
|
||||||
|
|||||||
Reference in New Issue
Block a user