Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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)));
    }
}