diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index d4a45e6..277c4b7 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -28,9 +28,7 @@ permissions: jobs: release-please: - # Temporarily disabled until post-daemon/two-db-model work is complete. - # Re-enable by removing the `false &&` prefix below. - if: false && github.repository == 'tableau/hyper-api-rust' + if: github.repository == 'tableau/hyper-api-rust' runs-on: ubuntu-latest steps: - uses: googleapis/release-please-action@v5 diff --git a/README.md b/README.md index a23a471..ff2f4e8 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,11 @@ A **pure-Rust** implementation of the Hyper database API, using the PostgreSQL wire protocol with Hyper-specific extensions. Create, read, and manipulate Hyper database files (`.hyper`) without any C library dependencies. -> **Project Status — 0.1.x, AI-Engineered** +> **Project Status — 0.2.x, AI-Engineered** > > This crate was vibe-engineered with heavy use of AI coding assistants. The -> **0.1.x** line may still undergo large breaking changes; the public API -> won't settle until the 0.2.0 release. +> **0.2.x** line may still undergo large breaking changes; the public API +> won't settle until the 1.0.0 release. > > Contributors and reviewers should, at a minimum, run an **AI code reviewer** > over any changes, following the conventions, layering rules, and patterns diff --git a/hyperdb-mcp/CHANGELOG.md b/hyperdb-mcp/CHANGELOG.md index 4217a95..1198788 100644 --- a/hyperdb-mcp/CHANGELOG.md +++ b/hyperdb-mcp/CHANGELOG.md @@ -143,6 +143,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Fixed +- **Chart x-axis tick label thinning.** Long categorical line/scatter + charts (e.g. a 90-point hourly TIMESTAMP series) used to render with + only ONE visible x-axis label. The old logic blanked individual + labels at non-step indices, but `plotters` picks its own tick + *positions* on the float axis and rounds them to integer indices — + so almost none of the chosen ticks landed on a kept index, and the + formatter returned empty strings for the rest. The chart layer now + computes a target tick count from chart width and label sizes and + passes that count to `plotters` via `.x_labels(N)`; every drawn + tick carries its real label. Same `+00:00` suffix stripping for + shared TIMESTAMPTZ offsets is preserved (now isolated in + `strip_shared_tz_suffix`). +- **Line / scatter charts over `DATE`, `TIMESTAMP`, and `TIMESTAMPTZ` + columns now use a proportional time axis** instead of the previous + categorical-with-evenly-spaced-ticks behavior. Real-world time gaps + between data points are now reflected in the chart's x-axis: a + series at `2026-05-01 08:00`, `2026-05-01 12:30`, `2026-05-02 06:15` + shows the 4.5-hour and 17.75-hour gaps proportionally instead of + flattening every interval to the same width. Tick labels are + formatted via `chrono` in a form that matches the input kind: + `%Y-%m-%d` for DATE, `%Y-%m-%d %H:%M:%S` for TIMESTAMP, and + `%Y-%m-%d %H:%M:%S%:z` for TIMESTAMPTZ (the offset captured from the + first row, so a uniformly-`+05:30` series reports IST throughout). + Set `x_as_category: true` to opt out and force the previous + categorical layout (e.g. for charts where evenly-spaced bins are + more readable than proportional gaps). TEXT x columns continue to + render categorically as before. Bar charts are unaffected — they + remain categorical regardless of x type, which matches reader + expectations for grouped data. - **Watcher recovery after hyperd restart.** The watcher's connection pool now auto-rebuilds when a per-file ingest hits a connection-lost error (typically after the daemon restarts hyperd). Each ingest gets diff --git a/hyperdb-mcp/README.md b/hyperdb-mcp/README.md index d10c489..f73e505 100644 --- a/hyperdb-mcp/README.md +++ b/hyperdb-mcp/README.md @@ -1,5 +1,7 @@ # hyperdb-mcp +> **Note:** This crate was vibe-engineered with heavy use of AI coding assistants. The 0.1.x line may still undergo large breaking changes; the public API won't settle until the 1.0.0 release. + An MCP (Model Context Protocol) server that turns the Hyper columnar database into an instant SQL analytics engine. Data flows in from other MCP plugins or files, lands in Hyper automatically, and becomes queryable with SQL — no setup, no schema files, no database management. Built on the pure-Rust [`hyperdb-api`](../hyperdb-api/) crate for maximum performance: 22M+ rows/sec inserts, 18M+ rows/sec queries, constant memory for billion-row results. diff --git a/hyperdb-mcp/src/attach.rs b/hyperdb-mcp/src/attach.rs index 70d6645..915ed4f 100644 --- a/hyperdb-mcp/src/attach.rs +++ b/hyperdb-mcp/src/attach.rs @@ -727,6 +727,14 @@ pub fn validate_output_path(path: &str, kind: &str) -> Result format!("{kind} path '{path}' has no file-name component"), ) })?; + if !parent.exists() { + std::fs::create_dir_all(parent).map_err(|e| { + McpError::new( + ErrorCode::InternalError, + format!("Failed to create parent directory for {kind} path '{path}': {e}"), + ) + })?; + } let canonical_parent = std::fs::canonicalize(parent).map_err(|e| { McpError::new( ErrorCode::FileNotFound, @@ -924,14 +932,19 @@ mod tests { } #[test] - fn validate_output_path_rejects_missing_parent() { - // Build a platform-portable absolute path with a missing parent. - // Hardcoded "/definitely/..." paths are not absolute on Windows. - let missing_parent = std::env::temp_dir() - .join("hyper_mcp_validate_output_missing_parent_99999") - .join("out.csv"); - let err = validate_output_path(missing_parent.to_str().unwrap(), "export").unwrap_err(); - assert_eq!(err.code, ErrorCode::FileNotFound); + fn validate_output_path_creates_missing_parent() { + let parent = std::env::temp_dir().join("hyper_mcp_validate_output_missing_parent_99999"); + // Clean up from any prior run. + let _ = std::fs::remove_dir_all(&parent); + assert!(!parent.exists()); + + let target = parent.join("out.csv"); + let canonical = + validate_output_path(target.to_str().unwrap(), "export").expect("should create parent"); + assert!(canonical.is_absolute()); + assert!(parent.exists(), "parent directory should have been created"); + // Clean up. + let _ = std::fs::remove_dir_all(&parent); } #[test] diff --git a/hyperdb-mcp/src/chart.rs b/hyperdb-mcp/src/chart.rs index aa254fb..0f1772d 100644 --- a/hyperdb-mcp/src/chart.rs +++ b/hyperdb-mcp/src/chart.rs @@ -10,8 +10,8 @@ //! # Supported Chart Types //! //! - **Bar** — categorical x-axis by default; multi-series supported via `series` column. -//! - **Line** — numeric x-axis by default; set `x_as_category = true` for DATE/string x. -//! - **Scatter** — numeric x-axis by default; same `x_as_category` option as line. +//! - **Line** — auto-detects categorical x (DATE/TIMESTAMP/TEXT); override with `x_as_category`. +//! - **Scatter** — same auto-detection as line. //! - **Histogram** — single numeric column binned into N buckets (default 20). //! //! # Rendering Pipeline @@ -38,6 +38,7 @@ )] use crate::error::{ErrorCode, McpError}; +use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, TimeZone, Utc}; use plotters::prelude::*; use plotters::style::colors; use serde_json::Value; @@ -388,18 +389,24 @@ pub struct ChartOptions { /// Override the chart-type-specific default for how the x column is /// interpreted: /// - /// - `None` (default): use the chart type's natural behavior — `Bar` - /// treats x as categorical, `Line` / `Scatter` require numeric x. - /// - `Some(true)`: force categorical even on `Line` / `Scatter`. - /// Essential for plotting values whose natural axis is a string / - /// date / enum (e.g. `SELECT day, happiness_score` where `day` is a - /// `DATE`). - /// - `Some(false)`: force numeric even on `Bar` (rarely useful — bar - /// charts are almost always categorical). + /// - `None` (default): auto-detect from the first row's x value. + /// - For `Bar`: always categorical. + /// - For `Line` / `Scatter`: numeric x → numeric axis; + /// DATE / TIMESTAMP / TIMESTAMPTZ string → **proportional time + /// axis** (positions are real Unix epoch seconds, ticks formatted + /// in the matching kind); TEXT → categorical fallback. + /// - `Some(true)`: force categorical layout (synthetic sequential + /// x positions, original strings as tick labels). Useful when you + /// want even spacing on temporal data — e.g. one bar per business + /// day with no visual gap for weekends. + /// - `Some(false)`: force numeric x. Errors for non-numeric inputs + /// on `Line` / `Scatter`. Rarely useful on `Bar`. /// /// When categorical mode is active the rendered x axis uses the /// original string representation of each distinct x value as its - /// tick label, in the order x values are first seen. + /// tick label, in the order x values are first seen. When time mode + /// is active, gaps between data points reflect real wall-clock time + /// rather than insertion order. pub x_as_category: Option, /// Fix the x-axis range as `[min, max]`. When set, auto-scaling is /// skipped and all frames/charts share the same x extent. Useful for @@ -600,6 +607,120 @@ fn as_string(v: &Value) -> String { } } +/// Compact categorical tick labels by stripping a shared trailing +/// timezone offset, when every label ends with the same one (typical +/// for TIMESTAMPTZ data stored in UTC where every row reports `+00:00`). +/// +/// Returns labels unchanged when there's no shared suffix or fewer than +/// two labels. Tick *count* selection lives in [`auto_tick_count`]; this +/// pass is purely about removing redundant characters from each label. +fn strip_shared_tz_suffix(labels: &[String]) -> Vec { + if labels.len() <= 1 { + return labels.to_vec(); + } + let Some(suffix) = shared_tz_suffix(labels) else { + return labels.to_vec(); + }; + labels + .iter() + .map(|l| { + l.strip_suffix(suffix.as_str()) + .unwrap_or(l) + .trim() + .to_string() + }) + .collect() +} + +/// Decide how many tick labels `plotters` should draw on a categorical +/// x-axis given the labels we plan to render and the chart pixel width. +/// +/// We pass the result to `.x_labels(N)` so `plotters` distributes tick +/// positions across the categorical range. The formatter then renders +/// the *real* label at each position — never blanks — so the user sees +/// a usable, evenly-spaced subset rather than a sea of empty strings. +/// +/// Heuristic: estimate per-label pixel width as +/// `max_label_chars * 7px + 10px` (close to plotters' default mesh +/// font), divide the chart width by that, then clamp to +/// `[2, labels.len()]`. Returns `labels.len()` directly when there +/// are 0 or 1 labels. +/// +/// # Why not blank labels at non-step indices? +/// +/// `plotters` picks its own tick *positions* on the float axis (e.g. +/// `0.0, 4.7, 9.4, …` for a 0..89 categorical range). Rounding those +/// back to integer indices rarely lands on the same indices a "keep +/// every Nth, blank the rest" rule would preserve, so most ticks +/// would render as empty strings. Telling `plotters` how many ticks +/// to draw and always returning a real label is the only stable fix. +/// +/// # Caveat: `plotters` rounds down to the next "nice" subdivision +/// +/// `plotters::compute_f64_key_points` picks the smallest scale (most +/// ticks) such that `npoints ≤ max_points`, drawing scales from a +/// fixed band table `{1, 2, 5, 10, 20, 50, 100, …}`. So a wider chart +/// requesting 9 ticks across a 0..89 range still ends up with 5 ticks +/// (band 20), because the next denser band gives 10 ticks > 9. The +/// fix for that is *not* to multiply the request — at 800 px width 10 +/// labels of 19 chars each (1430 px) would overlap. The proper fix +/// for time-series charts is the proportional time-axis path, where +/// `plotters` picks nice time intervals against real epoch positions +/// and the band-rounding artifact disappears entirely. +fn auto_tick_count(labels: &[String], chart_width: u32) -> usize { + if labels.len() <= 1 { + return labels.len(); + } + let max_chars = labels.iter().map(|l| l.chars().count()).max().unwrap_or(1); + tick_count_for_label_width(max_chars, chart_width).min(labels.len()) +} + +/// Compute how many tick labels can fit horizontally, given a typical +/// label character count and the chart pixel width. Pure width math — +/// no clamping against a label count or label slice. Use this when +/// the actual label list isn't available up front (e.g. the temporal +/// branch generates labels lazily inside the formatter closure). +/// +/// Returns at least 2 so the axis stays informative even when labels +/// would technically overlap. +fn tick_count_for_label_width(label_chars: usize, chart_width: u32) -> usize { + let per_label_px = u32::try_from(label_chars) + .unwrap_or(u32::MAX) + .saturating_mul(7) + .saturating_add(10); + let fits = chart_width.saturating_div(per_label_px.max(1)) as usize; + fits.max(2) +} + +/// If all labels share a trailing timezone offset pattern like `+00:00` +/// or `-05:30`, return that suffix. Returns `None` if labels differ or +/// have no offset. +fn shared_tz_suffix(labels: &[String]) -> Option { + let first = labels.first()?; + // Match pattern: space or 'T' followed by time, then +/-HH:MM at the end + let offset_start = first.rfind('+').or_else(|| { + // Careful: don't match the '-' in "2026-05-01" + let last_minus = first.rfind('-')?; + // Only if it's after a ':' (i.e. part of time, not date) + if first[..last_minus].ends_with(|c: char| c.is_ascii_digit()) && last_minus > 10 { + Some(last_minus) + } else { + None + } + })?; + let suffix = &first[offset_start..]; + // Must look like +HH:MM or -HH:MM (6 chars) + if suffix.len() != 6 { + return None; + } + // Verify all labels share this suffix + if labels.iter().all(|l| l.ends_with(suffix)) { + Some(suffix.to_string()) + } else { + None + } +} + /// Collect distinct x values and their original string labels from a /// [`SeriesMap`], in ascending x-value order. /// @@ -631,6 +752,162 @@ fn collect_categories(groups: &SeriesMap) -> Vec<(f64, String)> { .collect() } +/// Discriminator for temporal x-axis input formats. Drives both the +/// date parser ([`parse_temporal`]) and the time-axis label formatter, +/// so a chart with `DATE` x values doesn't waste pixels on `00:00:00` +/// suffixes and a `TIMESTAMPTZ` chart preserves its timezone offset on +/// rendered tick labels. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TemporalKind { + /// `YYYY-MM-DD` — labels rendered as `%Y-%m-%d`, ticks land at + /// midnight UTC. + Date, + /// `YYYY-MM-DD HH:MM:SS` — labels rendered as `%Y-%m-%d %H:%M:%S`, + /// positioned at their face-value UTC equivalent (TIMESTAMP is + /// timezone-naive by definition). + DateTime, + /// `YYYY-MM-DD HH:MM:SS+HH:MM` — wrapped offset is the seconds + /// east of UTC parsed from the *first* row. Subsequent rows are + /// positioned in true UTC and re-rendered in this offset's local + /// time, so a chart over uniformly-`+00:00` data displays UTC + /// labels and a chart over `+05:30` data displays IST. + DateTimeTz(i32), +} + +/// How to interpret the x column when extracting f64 axis positions. +/// +/// Drives [`group_series`] and the corresponding rendering branch in +/// [`line_or_scatter`] / [`draw_bar`]. `Temporal` is the new mode added +/// for proportional time-axis rendering: x positions are real Unix +/// epoch seconds (so 6 hours apart on the wire are 6 hours apart on +/// the chart), and tick labels are formatted via chrono. +#[derive(Debug, Clone, Copy)] +enum XMode { + /// X values must be JSON numbers; positions pass through directly. + Numeric, + /// X values are stringified and assigned synthetic sequential + /// indices in first-seen order. All positions are integers, so + /// gaps in real-world spacing are flattened. + Categorical, + /// X values are parsed as temporal strings and positioned at their + /// Unix epoch seconds. Spacing is proportional to real time; tick + /// labels use a chrono format derived from the detected `kind`. + Temporal(TemporalKind), +} + +/// Parse a SQL temporal string ([`Value`] of `String` shape) into +/// `(kind, epoch_seconds_as_f64)`. Returns `None` when the value isn't +/// a recognized DATE / TIMESTAMP / TIMESTAMPTZ form. +/// +/// Recognized formats (most-specific first): +/// - `YYYY-MM-DD HH:MM:SS+HH:MM` and `T` separator → [`TemporalKind::DateTimeTz`] +/// - `YYYY-MM-DD HH:MM:SS+HHMM` (no colon in offset) +/// - `YYYY-MM-DD HH:MM:SS` (and fractional seconds) → [`TemporalKind::DateTime`] +/// - `YYYY-MM-DD HH:MM` (no seconds) → [`TemporalKind::DateTime`] +/// - `YYYY-MM-DD` → [`TemporalKind::Date`] +/// +/// `DateTime` strings are treated as UTC for positioning purposes — +/// they're naive by definition, so we have no other choice. The label +/// formatter will reproduce the input format faithfully. +fn parse_temporal(s: &str) -> Option<(TemporalKind, f64)> { + const TZ_FORMATS: &[&str] = &[ + "%Y-%m-%d %H:%M:%S%:z", + "%Y-%m-%dT%H:%M:%S%:z", + "%Y-%m-%d %H:%M:%S%z", + "%Y-%m-%dT%H:%M:%S%z", + "%Y-%m-%d %H:%M:%S%.f%:z", + "%Y-%m-%dT%H:%M:%S%.f%:z", + ]; + for fmt in TZ_FORMATS { + if let Ok(dt) = DateTime::::parse_from_str(s, fmt) { + let offset = dt.offset().local_minus_utc(); + return Some((TemporalKind::DateTimeTz(offset), dt.timestamp() as f64)); + } + } + + const DT_FORMATS: &[&str] = &[ + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%d %H:%M:%S%.f", + "%Y-%m-%dT%H:%M:%S%.f", + "%Y-%m-%d %H:%M", + "%Y-%m-%dT%H:%M", + ]; + for fmt in DT_FORMATS { + if let Ok(dt) = NaiveDateTime::parse_from_str(s, fmt) { + return Some(( + TemporalKind::DateTime, + Utc.from_utc_datetime(&dt).timestamp() as f64, + )); + } + } + + if let Ok(date) = NaiveDate::parse_from_str(s, "%Y-%m-%d") { + let dt = date.and_hms_opt(0, 0, 0)?; + return Some(( + TemporalKind::Date, + Utc.from_utc_datetime(&dt).timestamp() as f64, + )); + } + + None +} + +/// Decide the x mode for a line/scatter chart from the first row's +/// x value. Used when the caller didn't explicitly set `x_as_category`. +/// +/// Priority: +/// 1. Numeric (JSON number) → [`XMode::Numeric`]. +/// 2. String parsing as DATE/TIMESTAMP/TIMESTAMPTZ → [`XMode::Temporal`]. +/// 3. Anything else (TEXT, missing) → [`XMode::Categorical`] fallback. +fn detect_line_x_mode(rows: &[Value], x_col: &str) -> XMode { + let Some(x_raw) = rows + .first() + .and_then(Value::as_object) + .and_then(|obj| obj.get(x_col)) + else { + return XMode::Categorical; + }; + if as_number(x_raw).is_some() { + return XMode::Numeric; + } + if let Some(s) = x_raw.as_str() { + if let Some((kind, _)) = parse_temporal(s) { + return XMode::Temporal(kind); + } + } + XMode::Categorical +} + +/// Format a Unix epoch seconds tick value as a human-readable date +/// string in a form matching the originally detected [`TemporalKind`]. +fn format_temporal_tick(seconds: f64, kind: TemporalKind) -> String { + if !seconds.is_finite() { + return String::new(); + } + #[expect( + clippy::cast_possible_truncation, + reason = "tick positions for typical chart ranges (1970..2100) fit comfortably in i64; pre-flight is_finite() guards NaN/inf, and timestamp_opt() returns None on out-of-range values which we map to empty string" + )] + let secs_i64 = seconds.round() as i64; + match kind { + TemporalKind::Date => Utc + .timestamp_opt(secs_i64, 0) + .single() + .map(|dt| dt.format("%Y-%m-%d").to_string()) + .unwrap_or_default(), + TemporalKind::DateTime => Utc + .timestamp_opt(secs_i64, 0) + .single() + .map(|dt| dt.naive_utc().format("%Y-%m-%d %H:%M:%S").to_string()) + .unwrap_or_default(), + TemporalKind::DateTimeTz(tz_offset) => FixedOffset::east_opt(tz_offset) + .and_then(|off| off.timestamp_opt(secs_i64, 0).single()) + .map(|dt| dt.format("%Y-%m-%d %H:%M:%S%:z").to_string()) + .unwrap_or_default(), + } +} + /// Group rows into (`series_name`, points) buckets, extracting x and y values. /// When `series_col` is None, all points land in a single unnamed series. fn group_series( @@ -638,7 +915,7 @@ fn group_series( x_col: &str, y_col: &str, series_col: Option<&str>, - x_as_category: bool, + x_mode: XMode, ) -> Result { let mut groups: SeriesMap = BTreeMap::new(); let mut category_index: BTreeMap = BTreeMap::new(); @@ -655,16 +932,27 @@ fn group_series( let x_raw = obj.get(x_col).cloned().unwrap_or(Value::Null); let x_label = as_string(&x_raw); - let x_val = if x_as_category { - let next = category_index.len() as f64; - *category_index.entry(x_label.clone()).or_insert(next) - } else { - as_number(&x_raw).ok_or_else(|| { + let x_val = match x_mode { + XMode::Categorical => { + let next = category_index.len() as f64; + *category_index.entry(x_label.clone()).or_insert(next) + } + XMode::Numeric => as_number(&x_raw).ok_or_else(|| { McpError::new( ErrorCode::SchemaMismatch, format!("Column '{x_col}' is missing or not numeric in at least one row"), ) - })? + })?, + XMode::Temporal(_) => parse_temporal(&x_label) + .map(|(_, ts)| ts) + .ok_or_else(|| { + McpError::new( + ErrorCode::SchemaMismatch, + format!( + "Column '{x_col}' value '{x_label}' is not a recognized DATE / TIMESTAMP / TIMESTAMPTZ form" + ), + ) + })?, }; let series_key = match series_col { @@ -739,16 +1027,16 @@ where let x_col = require_column(&opts.x_column, "x")?; let y_col = require_column(&opts.y_column, "y")?; - // Bar charts default to categorical x axis; `ChartOptions::x_as_category` - // lets callers force numeric if they really want to. - let x_as_category = opts.x_as_category.unwrap_or(true); - let groups = group_series( - rows, - x_col, - y_col, - opts.series_column.as_deref(), - x_as_category, - )?; + // Bar charts default to categorical x axis; `ChartOptions::x_as_category=Some(false)` + // lets callers force numeric if they really want to. Bar charts never + // use temporal mode — even time-series bar charts visually expect + // discrete bars at evenly-spaced positions. + let x_mode = if opts.x_as_category == Some(false) { + XMode::Numeric + } else { + XMode::Categorical + }; + let groups = group_series(rows, x_col, y_col, opts.series_column.as_deref(), x_mode)?; let categories = collect_categories(&groups); @@ -780,16 +1068,13 @@ where .build_cartesian_2d(x_min..x_max, (y_min - y_pad)..(y_max + y_pad)) .map_err(draw_err)?; - let labels: Vec = categories.iter().map(|(_, l)| l.clone()).collect(); + let raw_labels: Vec = categories.iter().map(|(_, l)| l.clone()).collect(); + let labels = strip_shared_tz_suffix(&raw_labels); + let tick_count = auto_tick_count(&labels, opts.width); chart .configure_mesh() - .x_labels(categories.len().min(20)) + .x_labels(tick_count) .x_label_formatter(&|v| { - // Plotters passes us category-index floats that `collect_categories` - // generated from `0..labels.len()`; the round-trip stays within - // `isize` range. Clamp negative values to the out-of-range branch - // and rely on `usize::try_from` to surface any stray negative tick - // as an empty label rather than wrapping. #[expect( clippy::cast_possible_truncation, reason = "axis tick value originated as an integer index into `labels`; the subsequent `usize::try_from` + length check make out-of-range ticks render as the empty-string branch" @@ -879,16 +1164,19 @@ where { let x_col = require_column(&opts.x_column, "x")?; let y_col = require_column(&opts.y_column, "y")?; - // Line and scatter default to numeric x; callers with non-numeric x - // (dates, labels, enums) opt in via `ChartOptions::x_as_category`. - let x_as_category = opts.x_as_category.unwrap_or(false); - let groups = group_series( - rows, - x_col, - y_col, - opts.series_column.as_deref(), - x_as_category, - )?; + // Decide the x mode: + // - Explicit `x_as_category=Some(true)` → Categorical (force). + // - Explicit `x_as_category=Some(false)` → Numeric (force). + // - Default (None): peek at the first row's x value: + // - parses as DATE/TIMESTAMP/TIMESTAMPTZ → Temporal (proportional time axis). + // - non-numeric (TEXT) → Categorical fallback. + // - numeric → Numeric. + let x_mode = match opts.x_as_category { + Some(true) => XMode::Categorical, + Some(false) => XMode::Numeric, + None => detect_line_x_mode(rows, x_col), + }; + let groups = group_series(rows, x_col, y_col, opts.series_column.as_deref(), x_mode)?; let auto = bounds(&groups); let (rx_min, rx_max, ry_min, ry_max) = apply_ranges(auto, opts); @@ -903,44 +1191,71 @@ where let mut chart = ChartBuilder::on(root) .caption(&title, ("sans-serif", 22)) .margin(10) - .x_label_area_size(if x_as_category { 60 } else { 50 }) + .x_label_area_size(match x_mode { + XMode::Categorical | XMode::Temporal(_) => 60, + XMode::Numeric => 50, + }) .y_label_area_size(70) .build_cartesian_2d(rx_min..rx_max, ry_min..ry_max) .map_err(draw_err)?; - // In categorical mode the x values are synthetic sequential indices - // assigned by `group_series` — the axis ticks need a formatter that - // translates the index back to the original string label, otherwise - // the rendered chart would show 0, 1, 2, ... where a reader expects - // dates or names. - if x_as_category { - let categories = collect_categories(&groups); - let labels: Vec = categories.iter().map(|(_, l)| l.clone()).collect(); - chart - .configure_mesh() - .x_desc(x_col) - .y_desc(y_col) - .x_labels(categories.len().min(20)) - .x_label_formatter(&|v| { - #[expect( - clippy::cast_possible_truncation, - reason = "axis tick value originated as an integer index into `labels`; the subsequent `usize::try_from` + length check make out-of-range ticks render as the empty-string branch" - )] - let idx = v.round() as isize; - usize::try_from(idx) - .ok() - .and_then(|i| labels.get(i).cloned()) - .unwrap_or_default() - }) - .draw() - .map_err(draw_err)?; - } else { - chart - .configure_mesh() - .x_desc(x_col) - .y_desc(y_col) - .draw() - .map_err(draw_err)?; + // Configure the x-axis ticks per mode: + // - Categorical: tick positions are synthetic indices; the formatter + // maps each back to the original string label. + // - Temporal: tick positions are real Unix epoch seconds (proportional + // to wall-clock time); the formatter renders each via chrono in a + // format matching the input kind (DATE / TIMESTAMP / TIMESTAMPTZ). + // - Numeric: pass-through; plotters' default float formatter is fine. + match x_mode { + XMode::Categorical => { + let categories = collect_categories(&groups); + let raw_labels: Vec = categories.iter().map(|(_, l)| l.clone()).collect(); + let labels = strip_shared_tz_suffix(&raw_labels); + let tick_count = auto_tick_count(&labels, opts.width); + chart + .configure_mesh() + .x_desc(x_col) + .y_desc(y_col) + .x_labels(tick_count) + .x_label_formatter(&|v| { + #[expect( + clippy::cast_possible_truncation, + reason = "axis tick value originated as an integer index into `labels`; the subsequent `usize::try_from` + length check make out-of-range ticks render as the empty-string branch" + )] + let idx = v.round() as isize; + usize::try_from(idx) + .ok() + .and_then(|i| labels.get(i).cloned()) + .unwrap_or_default() + }) + .draw() + .map_err(draw_err)?; + } + XMode::Temporal(kind) => { + // Sample one rendered tick label to size the per-tick budget. + // DATE → 10 chars, TIMESTAMP → 19, TIMESTAMPTZ → 25 (with + // `+HH:MM`). Floor at 10 so a degenerate sample still gets + // a reasonable per-label budget. + let sample = format_temporal_tick(rx_min, kind); + let sample_chars = sample.chars().count().max(10); + let tick_count = tick_count_for_label_width(sample_chars, opts.width); + chart + .configure_mesh() + .x_desc(x_col) + .y_desc(y_col) + .x_labels(tick_count) + .x_label_formatter(&|v| format_temporal_tick(*v, kind)) + .draw() + .map_err(draw_err)?; + } + XMode::Numeric => { + chart + .configure_mesh() + .x_desc(x_col) + .y_desc(y_col) + .draw() + .map_err(draw_err)?; + } } let mut total_plotted = 0usize; @@ -1204,3 +1519,250 @@ where root.present().map_err(draw_err)?; Ok(values.len()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn s(strs: &[&str]) -> Vec { + strs.iter().map(|s| (*s).to_string()).collect() + } + + #[test] + fn strip_shared_tz_suffix_drops_uniform_offset() { + let labels = s(&[ + "2026-05-01 08:00:00+00:00", + "2026-05-02 06:15:00+00:00", + "2026-05-03 18:30:00+00:00", + ]); + let stripped = strip_shared_tz_suffix(&labels); + assert_eq!( + stripped, + s(&[ + "2026-05-01 08:00:00", + "2026-05-02 06:15:00", + "2026-05-03 18:30:00", + ]) + ); + } + + #[test] + fn strip_shared_tz_suffix_handles_non_utc_offset() { + let labels = s(&["2026-05-01 08:00:00+05:30", "2026-05-02 06:15:00+05:30"]); + let stripped = strip_shared_tz_suffix(&labels); + assert_eq!( + stripped, + s(&["2026-05-01 08:00:00", "2026-05-02 06:15:00",]) + ); + } + + #[test] + fn strip_shared_tz_suffix_preserves_when_offsets_differ() { + let labels = s(&["2026-05-01 08:00:00+00:00", "2026-05-02 06:15:00+05:30"]); + let stripped = strip_shared_tz_suffix(&labels); + assert_eq!(stripped, labels, "differing offsets must not be stripped"); + } + + #[test] + fn strip_shared_tz_suffix_preserves_plain_dates() { + let labels = s(&["2026-05-01", "2026-05-02", "2026-05-03"]); + let stripped = strip_shared_tz_suffix(&labels); + assert_eq!(stripped, labels, "DATE strings have no suffix to strip"); + } + + #[test] + fn strip_shared_tz_suffix_passes_through_one_or_zero() { + assert_eq!(strip_shared_tz_suffix(&[]), Vec::::new()); + let one = s(&["2026-05-01 08:00:00+00:00"]); + assert_eq!(strip_shared_tz_suffix(&one), one); + } + + #[test] + fn auto_tick_count_returns_all_when_labels_fit() { + // 5 short labels at width 800 — all fit comfortably. + let labels = s(&["A", "B", "C", "D", "E"]); + assert_eq!(auto_tick_count(&labels, 800), 5); + } + + #[test] + fn auto_tick_count_thins_long_timestamp_series() { + // 90 points like "2026-01-01 13:00:00" (19 chars). + // per_label_px = 19*7 + 10 = 143; fits = 800/143 = 5. + // The fix's contract: the count plotters is told MUST be ≥ 2 + // (so the axis stays informative) and ≤ labels.len(); for a + // 19-char label at 800px the heuristic should land in the + // 4..=8 band — comfortably small enough that no two adjacent + // ticks overlap. + let labels: Vec = (0..90) + .map(|i| format!("2026-01-{:02} {:02}:00:00", (i / 24) + 1, i % 24)) + .collect(); + let count = auto_tick_count(&labels, 800); + assert!( + (4..=8).contains(&count), + "expected 4..=8 ticks for 90 long labels at 800px, got {count}" + ); + assert!(count >= 2, "must always show at least 2 ticks"); + assert!(count <= labels.len(), "must never exceed label count"); + } + + #[test] + fn auto_tick_count_clamps_to_at_least_two() { + // Hypothetical: extremely wide labels at narrow chart width. + let labels = s(&[ + "x".repeat(200).as_str(), + "y".repeat(200).as_str(), + "z".repeat(200).as_str(), + ]); + assert!(auto_tick_count(&labels, 100) >= 2); + } + + #[test] + fn auto_tick_count_handles_one_or_zero_labels() { + assert_eq!(auto_tick_count(&[], 800), 0); + let one = s(&["only"]); + assert_eq!(auto_tick_count(&one, 800), 1); + } + + #[test] + fn auto_tick_count_caps_at_label_count() { + // Tiny labels at huge width — heuristic would say "many", but + // we should never exceed the actual label count. + let labels = s(&["A", "B", "C"]); + assert_eq!(auto_tick_count(&labels, 10_000), 3); + } + + #[test] + fn tick_count_for_label_width_does_not_clamp_to_label_count() { + // The width-only helper has no label-count input, so a 19-char + // estimate at 800px must compute fits=5 directly. Regression + // guard against the bug where an over-eager `min(labels.len())` + // collapsed the temporal-mode tick budget to 2. + // 19 chars * 7 + 10 = 143px → 800/143 = 5, 1400/143 = 9. + assert_eq!(tick_count_for_label_width(19, 800), 5); + assert_eq!(tick_count_for_label_width(19, 1400), 9); + // 10 chars * 7 + 10 = 80px → 800/80 = 10. DATE-only fits more. + assert_eq!(tick_count_for_label_width(10, 800), 10); + } + + #[test] + fn tick_count_for_label_width_clamps_to_at_least_two() { + assert_eq!(tick_count_for_label_width(200, 100), 2); + } + + #[test] + fn parse_temporal_recognizes_date() { + let (kind, secs) = parse_temporal("2026-05-01").expect("DATE should parse"); + assert_eq!(kind, TemporalKind::Date); + // Sanity: well after the epoch. + assert!(secs > 1.7e9); + } + + #[test] + fn parse_temporal_recognizes_timestamp() { + let (kind, secs1) = parse_temporal("2026-05-01 08:00:00").expect("TIMESTAMP should parse"); + assert_eq!(kind, TemporalKind::DateTime); + let (_, secs2) = parse_temporal("2026-05-01 12:30:00").expect("TIMESTAMP should parse"); + // Same date, 4.5 hours apart. + let delta = secs2 - secs1; + assert!( + (delta - 16_200.0).abs() < 1.0, + "expected 16200s gap, got {delta}" + ); + } + + #[test] + fn parse_temporal_recognizes_timestamptz_and_captures_offset() { + let (kind, _) = + parse_temporal("2026-05-01 08:00:00+05:30").expect("TIMESTAMPTZ should parse"); + match kind { + TemporalKind::DateTimeTz(off) => assert_eq!(off, 5 * 3600 + 30 * 60), + other => panic!("expected DateTimeTz, got {other:?}"), + } + } + + #[test] + fn parse_temporal_recognizes_t_separator() { + let (kind, _) = + parse_temporal("2026-05-01T08:00:00+00:00").expect("ISO T-form should parse"); + assert!(matches!(kind, TemporalKind::DateTimeTz(0))); + } + + #[test] + fn parse_temporal_rejects_non_temporal_strings() { + assert!(parse_temporal("alpha").is_none()); + assert!(parse_temporal("").is_none()); + assert!(parse_temporal("2026").is_none()); + // Numeric strings are NOT temporal — caller should treat as numeric. + assert!(parse_temporal("42").is_none()); + } + + #[test] + fn format_temporal_tick_round_trips_date() { + let (_, secs) = parse_temporal("2026-05-01").unwrap(); + assert_eq!(format_temporal_tick(secs, TemporalKind::Date), "2026-05-01"); + } + + #[test] + fn format_temporal_tick_round_trips_timestamp() { + let (_, secs) = parse_temporal("2026-05-01 08:30:00").unwrap(); + assert_eq!( + format_temporal_tick(secs, TemporalKind::DateTime), + "2026-05-01 08:30:00" + ); + } + + #[test] + fn format_temporal_tick_preserves_offset_for_timestamptz() { + let (kind, secs) = parse_temporal("2026-05-01 08:30:00+05:30").unwrap(); + assert_eq!( + format_temporal_tick(secs, kind), + "2026-05-01 08:30:00+05:30" + ); + } + + #[test] + fn format_temporal_tick_handles_nan() { + // Plotters can theoretically pass NaN/infinity for axis ticks + // when the range is degenerate. We must not panic. + assert_eq!(format_temporal_tick(f64::NAN, TemporalKind::Date), ""); + assert_eq!( + format_temporal_tick(f64::INFINITY, TemporalKind::DateTime), + "" + ); + } + + #[test] + fn detect_line_x_mode_picks_temporal_for_dates() { + let rows = vec![serde_json::json!({"ts": "2026-05-01"})]; + let mode = detect_line_x_mode(&rows, "ts"); + assert!(matches!(mode, XMode::Temporal(TemporalKind::Date))); + } + + #[test] + fn detect_line_x_mode_picks_temporal_for_timestamps() { + let rows = vec![serde_json::json!({"ts": "2026-05-01 08:00:00"})]; + let mode = detect_line_x_mode(&rows, "ts"); + assert!(matches!(mode, XMode::Temporal(TemporalKind::DateTime))); + } + + #[test] + fn detect_line_x_mode_picks_temporal_for_timestamptz() { + let rows = vec![serde_json::json!({"ts": "2026-05-01 08:00:00+00:00"})]; + let mode = detect_line_x_mode(&rows, "ts"); + assert!(matches!(mode, XMode::Temporal(TemporalKind::DateTimeTz(0)))); + } + + #[test] + fn detect_line_x_mode_falls_back_to_categorical_for_text() { + let rows = vec![serde_json::json!({"x": "alpha"})]; + let mode = detect_line_x_mode(&rows, "x"); + assert!(matches!(mode, XMode::Categorical)); + } + + #[test] + fn detect_line_x_mode_picks_numeric_for_numbers() { + let rows = vec![serde_json::json!({"x": 42.0})]; + let mode = detect_line_x_mode(&rows, "x"); + assert!(matches!(mode, XMode::Numeric)); + } +} diff --git a/hyperdb-mcp/src/main.rs b/hyperdb-mcp/src/main.rs index a617117..a6d9925 100644 --- a/hyperdb-mcp/src/main.rs +++ b/hyperdb-mcp/src/main.rs @@ -173,7 +173,7 @@ async fn run_daemon_mode( tracing_subscriber::registry() .with(filter) - .with(fmt::layer().with_writer(std::io::stderr)) + .with(fmt::layer().with_writer(std::io::stderr).with_ansi(false)) .with(fmt::layer().with_writer(file_writer).with_ansi(false)) .init(); @@ -210,7 +210,7 @@ async fn run_mcp_mode(cli: Cli) -> Result<(), Box> { tracing_subscriber::registry() .with(filter) - .with(fmt::layer().with_writer(std::io::stderr)) + .with(fmt::layer().with_writer(std::io::stderr).with_ansi(false)) .with(fmt::layer().with_writer(file_writer).with_ansi(false)) .init(); diff --git a/hyperdb-mcp/src/readme.rs b/hyperdb-mcp/src/readme.rs index 481ea21..70c6e7c 100644 --- a/hyperdb-mcp/src/readme.rs +++ b/hyperdb-mcp/src/readme.rs @@ -131,7 +131,13 @@ watcher targets the alias; call `unwatch_directory` first. - `export` — write a table or query result to a file (Parquet, Iceberg, Arrow IPC, CSV, .hyper). - `chart` — render a bar / line / scatter / histogram PNG from a SQL - query. + query. Data must be long-format (one numeric y column; use a `series` + column for grouping). On line/scatter charts, DATE / TIMESTAMP / + TIMESTAMPTZ x columns auto-detect to a **proportional time axis** + (real-world gaps reflected in spacing); TEXT x falls back to evenly + spaced categorical mode. Pass `x_as_category: true` to force + categorical even on temporal data. Wide-format data must be reshaped + with UNION ALL. - `copy_query` — run a SELECT across local + attached databases and insert the result into a target table (`mode`: `create`, `append`, `replace`). Cross-database analytics in one tool call. diff --git a/hyperdb-mcp/src/server.rs b/hyperdb-mcp/src/server.rs index 04ef74d..d709fbf 100644 --- a/hyperdb-mcp/src/server.rs +++ b/hyperdb-mcp/src/server.rs @@ -441,11 +441,11 @@ pub struct ChartParams { pub height: Option, /// Number of bins for histograms (default 20) pub bins: Option, - /// Treat the x column as categorical rather than numeric. Set to `true` - /// when plotting a `line` or `scatter` against a `DATE`, `TIMESTAMP`, - /// enum, or any other non-numeric x column; otherwise the chart will - /// reject the query with "column is missing or not numeric". Bar - /// charts are always categorical regardless of this flag. + /// Treat the x column as categorical rather than numeric. Auto-detected + /// from the first row's x value for line/scatter charts: DATE, TIMESTAMP, + /// TEXT, and other non-numeric types flip to categorical automatically. + /// Set explicitly to override auto-detection. Bar charts are always + /// categorical regardless of this flag. pub x_as_category: Option, /// Fix the x-axis range as [min, max]. Omit to auto-scale. Useful when /// comparing multiple charts at a consistent scale (e.g. [0, 1500] for @@ -2299,7 +2299,7 @@ impl HyperMcpServer { /// Render a chart (PNG or SVG) from a SQL query. #[tool( - description = "Render a chart (bar, line, scatter, or histogram) from a SQL query. Writes the image to disk by default and returns a short stats blob with the path — use `Read(path)` to display it (this keeps the MCP transcript small). Set `inline=true` to also receive the PNG/SVG bytes inline in the tool result; combine with `output_path` to get both.\n\n- `output_path`: explicit destination file path. Parent directory is created automatically. If omitted, a file is auto-generated under the system temp dir as `hyperdb-charts/chart---.`.\n- `inline`: when true, return the image bytes inline. Without `output_path`, suppresses the disk write entirely. With `output_path`, writes to disk AND returns inline. Defaults to false.\n- `format`: \"png\" (default) or \"svg\". Auto-derived from `output_path` extension when omitted. A mismatch between `format` and the path extension returns `INVALID_ARGUMENT`.\n- `overwrite`: default true. Set false to refuse overwriting an existing file (returns `PERMISSION_DENIED`).\n- `x_range` / `y_range`: fix axis extents across multiple charts (e.g. x_range=[0,1500], y_range=[0,1]).\n- `color_map`: stable per-series hex colors (e.g. {\"India\":\"#e41a1c\",\"China\":\"#ff7f0e\"}).\n- `label_points=true`: annotate each point with its series name instead of showing a legend — best when each series has exactly one point." + description = "Render a chart (bar, line, scatter, or histogram) from a SQL query. Writes the image to disk by default and returns a short stats blob with the path — use `Read(path)` to display it (this keeps the MCP transcript small). Set `inline=true` to also receive the PNG/SVG bytes inline in the tool result; combine with `output_path` to get both.\n\n**Data shape:** The query must return long-format data with one numeric `y` column. For multi-series charts, use a `series` column to split by category. If your data is wide-format (multiple value columns), reshape it with `UNION ALL` into (label, series, value) tuples before charting.\n\n**DATE/TIMESTAMP x-axis:** Line and scatter charts auto-detect non-numeric x columns. DATE, TIMESTAMP, and TIMESTAMPTZ values render with a **proportional time axis** — gaps between data points reflect real wall-clock time (4.5 h gap and 17 h gap don't look the same). Tick labels are formatted in the input kind: `%Y-%m-%d` for DATE, `%Y-%m-%d %H:%M:%S` for TIMESTAMP, with the originating timezone offset preserved for TIMESTAMPTZ. TEXT x columns fall back to evenly-spaced categorical mode. Set `x_as_category: true` to force categorical layout on temporal data (useful when even spacing reads better than proportional gaps).\n\n- `output_path`: explicit destination file path. Parent directory is created automatically (no need to pre-create it). If omitted, a file is auto-generated under the system temp dir as `hyperdb-charts/chart---.`.\n- `inline`: when true, return the image bytes inline. Without `output_path`, suppresses the disk write entirely. With `output_path`, writes to disk AND returns inline. Defaults to false.\n- `format`: \"png\" (default) or \"svg\". Auto-derived from `output_path` extension when omitted. A mismatch between `format` and the path extension returns `INVALID_ARGUMENT`.\n- `overwrite`: default true. Set false to refuse overwriting an existing file (returns `PERMISSION_DENIED`).\n- `x_range` / `y_range`: fix axis extents across multiple charts (e.g. x_range=[0,1500], y_range=[0,1]).\n- `color_map`: stable per-series hex colors (e.g. {\"India\":\"#e41a1c\",\"China\":\"#ff7f0e\"}).\n- `label_points=true`: annotate each point with its series name instead of showing a legend — best when each series has exactly one point." )] fn chart( &self, diff --git a/hyperdb-mcp/tests/chart_tests.rs b/hyperdb-mcp/tests/chart_tests.rs index 003c442..ee06ca4 100644 --- a/hyperdb-mcp/tests/chart_tests.rs +++ b/hyperdb-mcp/tests/chart_tests.rs @@ -107,13 +107,14 @@ fn line_chart_categorical_x() { let result = render_chart(&rows, &opts).unwrap(); assert_eq!(result.rows_plotted, 5); assert_eq!(result.mime_type, "image/png"); - // Without x_as_category this would fail the numeric-parse check. + // Without explicit x_as_category, auto-detection kicks in and + // recognizes the string x column as categorical. let without_category = ChartOptions { x_as_category: None, ..opts }; - let err = render_chart(&rows, &without_category).unwrap_err(); - assert!(err.message.contains("not numeric")); + let result2 = render_chart(&rows, &without_category).unwrap(); + assert_eq!(result2.rows_plotted, 5); } /// Scatter chart with no series column renders every row as one series. @@ -539,3 +540,95 @@ fn render_and_write_produces_valid_png_on_disk() { let on_disk = std::fs::read(&path).unwrap(); assert!(on_disk.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])); } + +/// Line chart with TIMESTAMPTZ-style labels auto-detects categorical mode +/// and renders without error. Labels with shared +00:00 suffix get shortened. +#[test] +fn line_chart_timestamptz_auto_categorical_and_label_shortening() { + let rows = vec![ + json!({"ts": "2026-05-01 08:00:00+00:00", "value": 100}), + json!({"ts": "2026-05-01 12:30:00+00:00", "value": 150}), + json!({"ts": "2026-05-02 06:15:00+00:00", "value": 200}), + json!({"ts": "2026-05-02 22:45:00+00:00", "value": 180}), + json!({"ts": "2026-05-03 10:00:00+00:00", "value": 220}), + json!({"ts": "2026-05-03 18:30:00+00:00", "value": 190}), + ]; + let opts = ChartOptions { + chart_type: ChartType::Line, + x_column: Some("ts".into()), + y_column: Some("value".into()), + // x_as_category deliberately left as None to test auto-detection + ..ChartOptions::default() + }; + let result = render_chart(&rows, &opts).unwrap(); + assert_eq!(result.rows_plotted, 6); +} + +/// Many TIMESTAMPTZ labels auto-thin to avoid overlap. +#[test] +fn line_chart_many_timestamps_auto_thins() { + let rows: Vec<_> = (0..30) + .map(|i| { + json!({ + "ts": format!("2026-05-{:02} 12:00:00+00:00", (i % 28) + 1), + "value": i * 10 + }) + }) + .collect(); + let opts = ChartOptions { + chart_type: ChartType::Line, + x_column: Some("ts".into()), + y_column: Some("value".into()), + ..ChartOptions::default() + }; + let result = render_chart(&rows, &opts).unwrap(); + assert_eq!(result.rows_plotted, 30); +} + +/// Regression: a 90-point hourly TIMESTAMP series used to render with +/// only ONE visible x-axis label because the old `shorten_labels` +/// blanked non-step indices and `plotters` picked tick positions that +/// rarely landed on a kept index. The fix tells `plotters` how many +/// ticks to draw up front, so every tick position carries a real label. +/// +/// Uses SVG mode so we can inspect the rendered text content directly. +#[test] +fn line_chart_long_timestamp_series_renders_multiple_visible_labels() { + let rows: Vec<_> = (0..90) + .map(|i| { + json!({ + "ts": format!("2026-01-{:02} {:02}:00:00", (i / 24) + 1, i % 24), + "value": i + }) + }) + .collect(); + let opts = ChartOptions { + chart_type: ChartType::Line, + format: ChartFormat::Svg, + x_column: Some("ts".into()), + y_column: Some("value".into()), + width: 800, + height: 480, + ..ChartOptions::default() + }; + let result = render_chart(&rows, &opts).unwrap(); + assert_eq!(result.rows_plotted, 90); + let svg = String::from_utf8(result.bytes).expect("SVG must be UTF-8"); + // Each visible x-axis tick label is rendered as an SVG + // element containing the literal label string. Every label in this + // series starts with "2026-01-", so counting that prefix gives the + // number of *visible* x-axis labels (the y-axis labels are numeric + // and won't match). + let visible_labels = svg.matches("2026-01-").count(); + assert!( + visible_labels >= 3, + "expected >= 3 visible x-axis labels for a 90-point series, got {visible_labels} \ + (regression: pre-fix value was 1)" + ); + // Also bound the upper end — too many would mean labels overlap; + // 800 / (19chars * 7px + 10px) ≈ 5 is the heuristic target. + assert!( + visible_labels <= 12, + "expected <= 12 visible labels (no overlap on 800px wide chart), got {visible_labels}" + ); +}