facett-grid/src/data.rs
//! Columnar data, either owned vectors or shared typed Arrow arrays with sparse edits.
use crate::arrow::ArrowColumn;
use std::fmt::Write as _;
/// Borrowed cell value; integer comparisons never pass through floating point.
#[derive(Clone, Copy, Debug)]
pub(crate) enum Value<'a> {
Int(i64),
UInt(u64),
Float(f64),
Bool(bool),
Text(&'a str),
}
impl Value<'_> {
pub(crate) fn numeric(self) -> Option<f64> {
match self {
Self::Int(v) => Some(v as f64),
Self::UInt(v) => Some(v as f64),
Self::Float(v) => Some(v),
Self::Bool(v) => Some(u8::from(v) as f64),
Self::Text(_) => None,
}
}
fn write_raw(self, out: &mut String) {
match self {
Self::Int(v) => write!(out, "{v}"),
Self::UInt(v) => write!(out, "{v}"),
Self::Float(v) => write!(out, "{v}"),
Self::Bool(v) => write!(out, "{v}"),
Self::Text(v) => {
out.push_str(v);
Ok(())
}
}
.expect("writing to a String");
}
}
/// One column's cells read ONCE, in source-row order with edits applied, into a
/// contiguous typed vector (`None` = null). A sort compares these instead of
/// reading both cells through [`Column::value`] on every comparison, which costs
/// an edit-map probe, a null check and a variant match each time.
pub(crate) enum Keys<'a> {
Int(Vec<Option<i64>>),
UInt(Vec<Option<u64>>),
Float(Vec<Option<f64>>),
Bool(Vec<Option<bool>>),
Text(Vec<Option<&'a str>>),
}
/// How a column should be interpreted for rendering, alignment, and sort. Derived
/// from the source type at ingest, but a consumer can override (e.g. force a 0..1
/// float to render as a progress bar).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CellKind {
/// Right-aligned, grouped thousands (e.g. `1,234,567`).
Int,
/// Right-aligned float; may render as a progress bar (`Render::Progress`).
Float,
/// Left-aligned text.
Text,
/// A low-cardinality string → rendered as a tinted chip.
Enum,
/// A check / dash glyph.
Bool,
/// A date/time string, dim-coloured.
Date,
}
impl CellKind {
/// Numbers (incl. bools-as-0/1 for heat) align right; text left.
pub fn align_right(self) -> bool {
matches!(self, CellKind::Int | CellKind::Float)
}
pub fn as_str(self) -> &'static str {
match self {
CellKind::Int => "int",
CellKind::Float => "float",
CellKind::Text => "text",
CellKind::Enum => "enum",
CellKind::Bool => "bool",
CellKind::Date => "date",
}
}
}
/// A typed, nullable column. Parallel `nulls` bitset kept as a `Vec<bool>` for
/// simplicity (a real bitmap is a later optimisation; correctness first).
#[derive(Clone, Debug)]
pub enum Column {
/// Shares the source buffers and stores only edited cells separately.
Arrow(ArrowColumn),
Int { v: Vec<i64>, nulls: Vec<bool> },
Float { v: Vec<f64>, nulls: Vec<bool> },
Text { v: Vec<String>, nulls: Vec<bool> },
Bool { v: Vec<bool>, nulls: Vec<bool> },
/// Stored as text but rendered/aligned as a date.
Date { v: Vec<String>, nulls: Vec<bool> },
}
impl Column {
pub fn len(&self) -> usize {
match self {
Column::Arrow(c) => c.len(),
Column::Int { v, .. } => v.len(),
Column::Float { v, .. } => v.len(),
Column::Text { v, .. } => v.len(),
Column::Bool { v, .. } => v.len(),
Column::Date { v, .. } => v.len(),
}
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn is_null(&self, i: usize) -> bool {
let nulls = match self {
Column::Arrow(c) => return c.value(i).is_none(),
Column::Int { nulls, .. }
| Column::Float { nulls, .. }
| Column::Text { nulls, .. }
| Column::Bool { nulls, .. }
| Column::Date { nulls, .. } => nulls,
};
nulls.get(i).copied().unwrap_or(false)
}
/// The default [`CellKind`] for this storage. `Enum` is decided at the
/// [`GridData`] level (needs a cardinality scan), so plain text reports `Text`.
pub fn kind(&self) -> CellKind {
match self {
Column::Arrow(c) => c.kind(),
Column::Int { .. } => CellKind::Int,
Column::Float { .. } => CellKind::Float,
Column::Text { .. } => CellKind::Text,
Column::Bool { .. } => CellKind::Bool,
Column::Date { .. } => CellKind::Date,
}
}
/// Numeric value for heat/data-bar/sparkline (bool → 0/1; text/date → None).
pub fn numeric(&self, i: usize) -> Option<f64> {
self.value(i)?.numeric()
}
pub(crate) fn value(&self, i: usize) -> Option<Value<'_>> {
if let Self::Arrow(c) = self {
return c.value(i);
}
if self.is_null(i) {
return None;
}
match self {
Self::Int { v, .. } => v.get(i).copied().map(Value::Int),
Self::Float { v, .. } => v.get(i).copied().map(Value::Float),
Self::Bool { v, .. } => v.get(i).copied().map(Value::Bool),
Self::Text { v, .. } | Self::Date { v, .. } => v.get(i).map(|v| Value::Text(v)),
Self::Arrow(_) => unreachable!(),
}
}
/// Fold every row's value, in row order with edits applied, in one sequential
/// pass. Whole-column readers (sums, ranges) use this, never
/// [`value`](Self::value) per row: on a shared Arrow column that lookup costs
/// ~3x a pre-PR #2 `Vec` read (0.3 -> 0.9-1.4 ms per 100k-row column, dwarf).
pub(crate) fn fold_values<'a, A>(&'a self, init: A, mut f: impl FnMut(A, usize, Option<Value<'a>>) -> A) -> A {
match self {
Self::Arrow(c) => c.fold_values(init, f),
_ => (0..self.len()).fold(init, |acc, row| f(acc, row, self.value(row))),
}
}
/// The column's sort [`Keys`], read once.
pub(crate) fn keys(&self) -> Keys<'_> {
fn owned<'a, T, K>(v: &'a [T], nulls: &[bool], key: impl Fn(&'a T) -> K) -> Vec<Option<K>> {
v.iter()
.enumerate()
.map(|(i, x)| (!nulls.get(i).copied().unwrap_or(false)).then(|| key(x)))
.collect()
}
match self {
Self::Arrow(c) => c.keys(),
Self::Int { v, nulls } => Keys::Int(owned(v, nulls, |&x| x)),
Self::Float { v, nulls } => Keys::Float(owned(v, nulls, |&x| x)),
Self::Bool { v, nulls } => Keys::Bool(owned(v, nulls, |&x| x)),
Self::Text { v, nulls } | Self::Date { v, nulls } => Keys::Text(owned(v, nulls, String::as_str)),
}
}
/// Min/max of the numeric values (for conditional-format normalisation).
/// `None` for non-numeric columns or all-null.
pub fn numeric_range(&self) -> Option<(f64, f64)> {
if !matches!(
self.kind(),
CellKind::Int | CellKind::Float | CellKind::Bool
) {
return None;
}
let (lo, hi, any) = self.fold_values((f64::INFINITY, f64::NEG_INFINITY, false), |(lo, hi, any), _, v| {
match v.and_then(Value::numeric) {
Some(x) if x.is_finite() => (lo.min(x), hi.max(x), true),
_ => (lo, hi, any),
}
});
any.then_some((lo, hi))
}
}
/// Group an integer with thousands separators: `1234567 → "1,234,567"`.
pub fn group_int(n: i64) -> String {
let magnitude = group_uint(n.unsigned_abs());
if n < 0 {
format!("-{magnitude}")
} else {
magnitude
}
}
fn group_uint(n: u64) -> String {
let digits = n.to_string();
let len = digits.len();
let mut out = String::with_capacity(len + len / 3 + 1);
for (i, b) in digits.bytes().enumerate() {
// A comma precedes every group of 3 counted from the right: insert one
// before digit `i` when the number of remaining digits is a multiple of 3.
if i != 0 && (len - i).is_multiple_of(3) {
out.push(',');
}
out.push(b as char);
}
out
}
/// Group a float: thousands on the integer part, up to 4 trailing fraction
/// digits (trimmed). Whole floats render without a fraction.
pub fn group_float(x: f64) -> String {
if !x.is_finite() {
return x.to_string();
}
let neg = x.is_sign_negative() && x != 0.0;
let a = x.abs();
let ip = a.trunc() as i64;
let frac = a.fract();
let mut s = group_int(ip);
if frac > 1e-9 {
let mut f = format!("{frac:.4}");
// strip leading "0" and trailing zeros
let tail = f.split_off(1); // ".xxxx"
let trimmed = tail.trim_end_matches('0');
if trimmed != "." {
s.push_str(trimmed);
}
}
if neg {
format!("-{s}")
} else {
s
}
}
/// A set of named, equal-length [`Column`]s — the grid's whole dataset. Columns
/// keep insertion order; rows are addressed by index. `enum_cols` records which
/// text columns are low-cardinality (rendered as chips).
#[derive(Clone, Debug, Default)]
pub struct GridData {
pub names: Vec<String>,
pub columns: Vec<Column>,
/// Per-column kind override (computed at build incl. enum detection).
pub kinds: Vec<CellKind>,
nrows: usize,
}
impl GridData {
/// Build from named columns; panics if lengths differ (caller's bug). Detects
/// low-cardinality text columns as `Enum` (≤ `enum_max` distinct, and the
/// distinct count is a small fraction of the rows).
pub fn new(named: Vec<(String, Column)>) -> Self {
let nrows = named.first().map(|(_, c)| c.len()).unwrap_or(0);
for (n, c) in &named {
assert_eq!(c.len(), nrows, "column `{n}` length {} != {nrows}", c.len());
}
let mut names = Vec::with_capacity(named.len());
let mut columns = Vec::with_capacity(named.len());
let mut kinds = Vec::with_capacity(named.len());
for (n, c) in named {
let k = detect_kind(&c, nrows);
names.push(n);
kinds.push(k);
columns.push(c);
}
Self { names, columns, kinds, nrows }
}
pub fn nrows(&self) -> usize {
self.nrows
}
pub fn ncols(&self) -> usize {
self.columns.len()
}
pub fn is_empty(&self) -> bool {
self.nrows == 0 || self.columns.is_empty()
}
/// The display string for cell (row, col) — the canonical TSV/copy form.
pub fn cell_string(&self, row: usize, col: usize) -> String {
match self.columns.get(col).and_then(|c| c.value(row)) {
Some(Value::Int(v)) => group_int(v),
Some(Value::UInt(v)) => group_uint(v),
Some(Value::Float(v)) => group_float(v),
Some(Value::Text(v)) => v.to_owned(),
Some(Value::Bool(v)) => v.to_string(),
None => String::new(),
}
}
/// The raw (ungrouped) string — used for editing and copying.
pub fn cell_raw(&self, row: usize, col: usize) -> String {
let mut out = String::new();
if let Some(value) = self.columns.get(col).and_then(|c| c.value(row)) {
value.write_raw(&mut out);
}
out
}
/// Reuse one formatting buffer; text is searched directly in its source buffer.
pub(crate) fn row_matches(&self, row: usize, needle: &str, scratch: &mut String) -> bool {
self.columns.iter().any(|c| {
let text = match c.value(row) {
Some(Value::Text(s)) => s,
value => {
scratch.clear();
if let Some(v) = value {
v.write_raw(scratch);
}
scratch.as_str()
}
};
needle.is_empty()
|| text
.as_bytes()
.windows(needle.len())
.any(|w| w.eq_ignore_ascii_case(needle.as_bytes()))
})
}
}
/// Decide a column's render kind, promoting low-cardinality `Text` to `Enum`.
fn detect_kind(c: &Column, nrows: usize) -> CellKind {
if c.kind() != CellKind::Text {
return c.kind();
}
let sample = nrows.min(2048);
let mut seen = Vec::new();
for i in 0..sample {
if let Some(Value::Text(s)) = c.value(i)
&& !seen.contains(&s)
{
seen.push(s);
if seen.len() > 12 {
return CellKind::Text;
}
}
}
if !seen.is_empty() && seen.len() < sample {
CellKind::Enum
} else {
CellKind::Text
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn group_int_inserts_thousands() {
assert_eq!(group_int(i64::MIN), "-9,223,372,036,854,775,808");
assert_eq!(group_int(0), "0");
assert_eq!(group_int(12), "12");
assert_eq!(group_int(1234), "1,234");
assert_eq!(group_int(1234567), "1,234,567");
assert_eq!(group_int(-9876543), "-9,876,543");
}
#[test]
fn group_float_groups_and_trims() {
assert_eq!(group_float(1234.0), "1,234");
assert_eq!(group_float(1234.5), "1,234.5");
assert_eq!(group_float(0.25), "0.25");
assert_eq!(group_float(-1234.75), "-1,234.75");
}
#[test]
fn detect_enum_vs_text() {
let enum_col = Column::Text {
v: (0..100).map(|i| ["A", "B", "C"][i % 3].to_string()).collect(),
nulls: vec![false; 100],
};
let text_col = Column::Text {
v: (0..100).map(|i| format!("row-{i}")).collect(),
nulls: vec![false; 100],
};
assert_eq!(detect_kind(&enum_col, 100), CellKind::Enum);
assert_eq!(detect_kind(&text_col, 100), CellKind::Text);
}
#[test]
fn numeric_range_skips_nulls() {
let c = Column::Int { v: vec![5, 0, 10, 0], nulls: vec![false, true, false, true] };
assert_eq!(c.numeric_range(), Some((5.0, 10.0)));
}
}