From 95b88a538d19c30936e1cc7638b8f5893fc55fef Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sun, 5 Apr 2026 14:05:33 -0700 Subject: [PATCH] feat(import): add Label field support for high-cardinality per-row data Add support for Label-kind categories to handle high-cardinality per-row fields like descriptions, IDs, and notes. These fields are stored alongside regular categories but default to Axis::None and are excluded from pivot category limits. Changes: - analyzer.rs: Label fields now default to accepted=true - wizard.rs: Collect and process label fields during model building, attaching label values as coordinates for each cell - category.rs: Add Label variant to CategoryKind enum - types.rs: Add add_label_category() method and update category counting to only include Regular-kind categories Co-Authored-By: fiddlerwoaroof/git-smart-commit (unsloth/Qwen3.5-35B-A3B-GGUF:Q5_K_M) --- src/import/analyzer.rs | 6 +++--- src/import/wizard.rs | 32 ++++++++++++++++++++++++++++++++ src/model/category.rs | 11 +++++++++-- src/model/types.rs | 25 +++++++++++++++++++++++-- 4 files changed, 67 insertions(+), 7 deletions(-) diff --git a/src/import/analyzer.rs b/src/import/analyzer.rs index 9b34c02..1db6146 100644 --- a/src/import/analyzer.rs +++ b/src/import/analyzer.rs @@ -40,7 +40,7 @@ impl FieldProposal { FieldKind::Category => "Category (dimension)", FieldKind::Measure => "Measure (numeric)", FieldKind::TimeCategory => "Time Category", - FieldKind::Label => "Label/Identifier (skip)", + FieldKind::Label => "Label (per-row, drill-view only)", } } } @@ -167,7 +167,7 @@ pub fn analyze_records(records: &[Value]) -> Vec { field, kind: FieldKind::Label, distinct_values: distinct_vec, - accepted: false, + accepted: true, date_format: None, date_components: vec![], }; @@ -178,7 +178,7 @@ pub fn analyze_records(records: &[Value]) -> Vec { field, kind: FieldKind::Label, distinct_values: vec![], - accepted: false, + accepted: true, date_format: None, date_components: vec![], } diff --git a/src/import/wizard.rs b/src/import/wizard.rs index ba1ed87..47bd906 100644 --- a/src/import/wizard.rs +++ b/src/import/wizard.rs @@ -94,6 +94,11 @@ impl ImportPipeline { .iter() .filter(|p| p.accepted && p.kind == FieldKind::Measure) .collect(); + let labels: Vec<&FieldProposal> = self + .proposals + .iter() + .filter(|p| p.accepted && p.kind == FieldKind::Label) + .collect(); if categories.is_empty() { return Err(anyhow!("At least one category must be accepted")); @@ -139,6 +144,11 @@ impl ImportPipeline { model.add_category(derived_name)?; } + // Create label categories (stored but not pivoted by default) + for lab in &labels { + model.add_label_category(&lab.field)?; + } + if !measures.is_empty() { model.add_category("Measure")?; if let Some(cat) = model.category_mut("Measure") { @@ -187,6 +197,28 @@ impl ImportPipeline { continue; } + // Attach label values as coords (missing labels become ""). + for lab in &labels { + let val = map + .get(&lab.field) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| { + map.get(&lab.field).and_then(|v| { + if v.is_null() { + None + } else { + Some(v.to_string()) + } + }) + }) + .unwrap_or_default(); + if let Some(cat) = model.category_mut(&lab.field) { + cat.add_item(&val); + } + coords.push((lab.field.clone(), val)); + } + for measure in &measures { if let Some(val) = map.get(&measure.field).and_then(|v| v.as_f64()) { let mut cell_coords = coords.clone(); diff --git a/src/model/category.rs b/src/model/category.rs index 208512e..2c4f224 100644 --- a/src/model/category.rs +++ b/src/model/category.rs @@ -59,11 +59,18 @@ pub enum CategoryKind { VirtualIndex, /// Items are the names of all regular categories + "Value". VirtualDim, + /// High-cardinality per-row field (description, id, note). Stored + /// alongside the data so it shows up in record/drill views, but + /// defaults to Axis::None and is excluded from pivot limits and the + /// auto Row/Column axis assignment. + Label, } impl CategoryKind { - pub fn is_virtual(&self) -> bool { - !matches!(self, CategoryKind::Regular) + /// True for user-managed pivot dimensions (what the category + /// count limit and auto axis assignment apply to). + pub fn is_regular(&self) -> bool { + matches!(self, CategoryKind::Regular) } } diff --git a/src/model/types.rs b/src/model/types.rs index a47a24d..5a0ea59 100644 --- a/src/model/types.rs +++ b/src/model/types.rs @@ -63,11 +63,11 @@ impl Model { pub fn add_category(&mut self, name: impl Into) -> Result { let name = name.into(); - // Virtuals don't count against the regular category limit + // Only regular pivot categories count against the limit. let regular_count = self .categories .values() - .filter(|c| !c.kind.is_virtual()) + .filter(|c| c.kind.is_regular()) .count(); if regular_count >= MAX_CATEGORIES { return Err(anyhow!("Maximum of {MAX_CATEGORIES} categories reached")); @@ -86,6 +86,27 @@ impl Model { Ok(id) } + /// Add a Label-kind category: stored alongside regular categories so + /// records views can display it, but default to `Axis::None` and + /// excluded from the pivot-category count limit. + pub fn add_label_category(&mut self, name: impl Into) -> Result { + use crate::model::category::CategoryKind; + use crate::view::Axis; + let name = name.into(); + if self.categories.contains_key(&name) { + return Ok(self.categories[&name].id); + } + let id = self.next_category_id; + self.next_category_id += 1; + let cat = Category::new(id, name.clone()).with_kind(CategoryKind::Label); + self.categories.insert(name.clone(), cat); + for view in self.views.values_mut() { + view.on_category_added(&name); + view.set_axis(&name, Axis::None); + } + Ok(id) + } + pub fn category_mut(&mut self, name: &str) -> Option<&mut Category> { self.categories.get_mut(name) }