# DBFlux Charting
DBFlux can turn a query result into a chart. The charting engine is fully
driver-agnostic: it inspects only the structured column metadata that every
driver populates, never a driver identifier or a database-specific type-name
string. This document describes the supported chart types, how the engine
auto-detects axes, how charts are persisted, and how a chart is created from
the UI.
For dashboards (grids of saved charts with a shared time range), the
visualization storage tables (`viz_*`), and the driver seams for
importing/browsing upstream dashboards, see [`DASHBOARDS.md`](./DASHBOARDS.md).
## Overview
The chart engine lives in the `dbflux_components` crate under
`crates/dbflux_components/src/chart/`. Its `mod.rs` describes the full
pipeline:
1. `detect` — auto-detects suitable columns from a `QueryResult` using
`ColumnKind` semantics only.
2. `spec` — chart and series specification types, plus constructors for
detection-driven and manual column selection.
3. `decimate` — LTTB (Largest-Triangle-Three-Buckets) downsampling to keep
painting fast on large datasets.
4. `axis` — tick generation and label formatting for numeric and time axes.
5. `legend` — element factory for the legend row.
6. `engine` — `ChartView`, the GPUI entity that owns chart state and renders
the canvas.
The standalone chart document UI lives in
`crates/dbflux_ui_document/src/chart_document/` (`mod.rs`, `render.rs`,
`pane.rs`). A `ChartDocument` owns a query, a connection, a chart spec, and a
`ChartShell`, and hosts its rendering through the shared `ResultPanel` chrome
in `crates/dbflux_components/src/result_panel/`.
## Chart types
Chart kinds are defined by the `ChartKind` enum in
`crates/dbflux_components/src/chart/spec.rs`:
| Variant | Description |
|---------|-------------|
| `Line` | Line chart. The default kind (`#[default]`); also the kind chosen by every `ChartSpec` constructor. |
| `Bar` | Bar chart. |
| `Scatter` | Scatter chart. |
| `Area` | Filled line chart; the area between the series line and the baseline is shaded. Shares Line's geometry and hover behaviour. |
| `StackedBar` | Stacked vertical bars. Each X position shows one bar per series, stacked cumulatively rather than grouped side-by-side. Series are aligned on their X values: a series with no value at an X adds nothing to that bar. The Y axis is re-scaled at render time to run from zero to the maximum stack sum, and the tick labels use that same range. |
| `Pie` | Pie chart. No X/Y axes; each visible series becomes one wedge sized by the sum of that series' Y values. |
`ChartKind` carries `#[serde(default)]` semantics on the containing
`ChartSpec.kind` field, so serialized chart specs that predate the `kind` field
deserialize to `Line`.
## ColumnKind and axis auto-detection
### ColumnKind
Auto-detection is driven entirely by the `ColumnKind` enum defined in
`crates/dbflux_core/src/query/types.rs`:
| Variant | Meaning |
|---------|---------|
| `Timestamp` | A date/time or timestamp column. |
| `Float` | A floating-point numeric column. |
| `Integer` | An integer numeric column. |
| `Text` | A text/string column. |
| `Unknown` | The driver could not classify this column. |
Each driver is responsible for setting `ColumnMeta::kind` on every column it
returns (see the "Adding a New Driver" rules in `AGENTS.md`). Columns left as
`Unknown` are never used as chart axes or series.
### Auto-detection rules
`detect_chart_columns` in `crates/dbflux_components/src/chart/detect.rs` applies
these rules, in order, to a `QueryResult`:
1. If the result has zero rows, return `EmptyResult`.
2. Pick the leftmost column with `kind == Timestamp` as the X axis. If none
exists, return `NoTimeColumn`.
3. Collect every other column with `kind == Float` or `kind == Integer`, in
column order, as the numeric Y series. If none remain, return
`NoNumericSeries`.
4. Otherwise return `Ok { time_col, numeric_cols }`.
The outcome is the `ChartDetection` enum, whose variants are `Ok`,
`NoTimeColumn`, `NoNumericSeries`, and `EmptyResult`.
### Why `type_name` and driver IDs are never inspected
The module-level documentation in `detect.rs` states that the detection module
is the boundary between the query-result model and the chart engine, and that it
inspects `ColumnKind` values — never `type_name` strings or driver identifiers.
The `detect_chart_columns` function reads only `column.kind`; it never reads
`column.type_name`, `column.name`, or any driver ID. This keeps the engine
fully decoupled from specific drivers, matching the driver/UI decoupling rule in
`AGENTS.md`: a driver makes its columns chartable purely by classifying them
with the correct `ColumnKind`.
Because `Unknown` is neither `Timestamp` nor `Float`/`Integer`, an unclassified
column can be neither an auto-detected X axis nor an auto-detected series. This
is intentional: it forces drivers to classify columns rather than letting the
engine guess from type strings.
### Axis-kind inference
When a `ChartSpec` is built, the X axis kind is inferred from the X column's
`ColumnKind`: `Timestamp` maps to `AxisKind::Time` (ticks formatted as
dates/times), everything else maps to `AxisKind::Numeric` (decimal ticks). The
`AxisSpec.unit` field is currently always `None`; it is a forward-compatibility
seam for future driver-supplied unit metadata.
### Numeric value extraction
When the engine extracts a numeric value from a cell (`extract_f64` in
`engine.rs`), it handles several `Value` shapes:
- `Value::Int` → cast to `f64`.
- `Value::Float` → used directly when finite; non-finite values are dropped.
- `Value::Decimal` (stored as a string to preserve precision) → parsed lossily
to `f64`, dropping non-finite or unparseable values. Drivers that classify
`NUMERIC`/`DECIMAL` columns as `ColumnKind::Float` (for example PostgreSQL
`NUMERIC`, MSSQL `DECIMAL`) flow through this path.
- `Value::Bool` → `true` maps to `1.0`, `false` to `0.0`, so `BIT`/`BOOLEAN`
columns that some drivers classify as `Integer` (for example MSSQL `BIT`)
remain plottable.
- `Value::Text` is parsed only for the time axis, as an RFC 3339 timestamp.
- `Value::Null` and all other shapes yield no value.
## Saved charts
A persisted chart is a `SavedChart` record, defined in
`crates/dbflux_components/src/saved_chart.rs`. Saved charts are stored in the
unified SQLite database — the `viz_saved_charts` table and its related
`viz_saved_chart_*` tables — through `SavedChartsRepository`, with an in-memory
cache managed by `SavedChartManager`
(`crates/dbflux_ui_base/src/saved_chart_manager.rs`). Writes go to the
repository first; the cache updates only on success.
A `SavedChart` persists:
- `id`, `name`, `profile_id` — identity, display name, and owning connection
profile.
- `source` — a `SavedChartSource`, either `Query { query }` (a query string
executed inside a `ChartDocument`) or `Collection { collection_ref,
time_window }` (a collection-browse source).
- `chart_spec` and `bindings` — the full rendering configuration (`ChartSpec`
and `BindingSpec`).
- `time_range_preset`, `refresh_policy`, `created_at`, `updated_at`.
Only the query string (or collection reference) is persisted; raw result data
is never stored.
### Opening a saved chart
`Workspace::open_saved_chart` (in
`crates/dbflux_ui/src/ui/views/workspace/actions.rs`) routes by source type:
- `Query` sources open a standalone `ChartDocument` via
`ChartDocument::from_saved`. `from_saved` and `validate_saved_source` reject
`Collection` sources; the workspace validates the source before allocating the
entity.
- `Collection` sources do not open a `ChartDocument`; they re-open the
underlying `DataDocument` in chart mode via `open_collection_document`.
### Deduplication
Open chart documents are deduplicated through the `DocumentKey::Chart {
saved_chart_id: Uuid }` variant in
`crates/dbflux_ui_document/src/dedup.rs`. Before opening a saved chart,
`open_saved_chart` calls `tab_manager.find_by_key(&DocumentKey::Chart { ... })`
and activates the existing tab instead of opening a duplicate. A chart document
created from an ad-hoc "Chart this query" action is not yet linked to a saved ID
and therefore is not deduplicated until it is saved.
## Creating a chart in the UI
There are four entry points.
### Chart button in the query editor
Any query that produces tabular results can be charted. In the query editor
toolbar, click the chart button (tooltip: "Open current query in a chart
document") to open the current query in a chart document.
### Chart this query
A data grid's context menu offers a "Chart this query" item. The item is gated
by `can_chart_from_context_menu` in
`crates/dbflux_ui_document/src/data_grid_panel/context_menu.rs`, which
requires both:
1. The panel's source is a `QueryResult` with a non-empty original query, and
2. `detect_chart_columns` on the current result returns `Ok`.
Selecting the item calls `Workspace::open_chart_from_query`, which constructs a
`ChartDocument::new` seeded with the query and connection, wraps it in a
`PaneHandle` via `ChartDocument::into_pane`, and opens it as a new tab. A
non-empty query causes the document to auto-execute on its first render.
### Open chart...
The "Open chart…" command lists saved charts (built by
`build_saved_chart_palette_items`) for the active profile, and opens the
selected chart via `open_saved_chart` as described above.
Charts can be saved per connection profile. To reopen a saved chart, run **Open
Chart...** from the command palette (`OpenSavedChart`), which lists the saved
charts for the current profile in a fuzzy overlay.
### Time-series collections
Opening a collection on a connection whose category is
`DatabaseCategory::TimeSeries` (an InfluxDB measurement, for example) gives its
data grid the Data, Chart and JSON views that query results have, plus **Both**,
which draws the chart above the grid. The first page opens in Both when
`detect_chart_columns` returns `Ok`, with the axes seeded
by `default_bindings_for_time_series` (time on X, the first numeric column on Y,
the first `Text` column as the group). A group draws one line per distinct
value of its column, labelled by that value, so a tag such as `host` gives each
host its own line. The default group applies only when that column holds at
most 12 distinct values in the result (`DEFAULT_GROUP_MAX_VALUES`). Otherwise
the chart starts ungrouped. A group picked in the axis bar has no such limit.
Data shows the rows in the grid rather
than the document tree other collections use. A refresh, whether manual,
automatic or a page change, keeps the view the user picked, and falls back to
Data only when the new page is no longer chartable.
The toolbar and the status bar name the browse by the query the driver runs,
taken from `QueryGenerator::collection_browse_query`, so the label is written in
the connection's own query language. A driver that does not implement that
method keeps the generic label.
### Saving
Inside a `ChartDocument`, the Save toolbar button opens a name prompt and then
calls `confirm_save`, which builds a `ChartSpec` from the last result (using
`detect_chart_columns` / `ChartSpec::from_detection` when detection succeeds)
and upserts a `SavedChart` into the app state's `saved_charts` manager. Saving
reuses the existing `saved_chart_id` when present so the record is overwritten
rather than duplicated.
```mermaid
flowchart TD
QR[QueryResult with ColumnMeta.kind] --> DET[detect_chart_columns]
DET -->|Ok time_col, numeric_cols| SPEC[ChartSpec::from_detection]
DET -->|NoTimeColumn / NoNumericSeries / EmptyResult| NA[Chart this query unavailable]
SPEC --> CD[ChartDocument + ChartShell]
CD --> CV[ChartView render]
CD -->|Save| SC[SavedChart in saved_charts.json]
SC -->|Open chart...| CD
```
## Point inspector
In the chart of a table or collection tab (the Chart and Both views), the point
inspector docks to the right of the chart while the pointer is over a point, or
while a point is highlighted from the keyboard (see
[Charts](KEYBOARD.md#charts) in the keyboard reference). It shows the series,
the point's X and Y values, the fields of the row the point comes from, and a
**Show in tree** button.
**Show in tree** selects that row in the table and moves the keyboard to it. From
the Chart view it switches to the table; Both keeps the chart above the table.
The result's table menu lists the same action under **Toolbar** while a point is
highlighted.
Charts of query editor results and chart tabs do not keep the row behind each
point, so they show no point inspector.
## Limitations
These limitations are grounded in the current code, not assumptions:
- Auto-detection requires at least one `Timestamp` column to pick an X axis;
without one, `detect_chart_columns` returns `NoTimeColumn` and "Chart this
query" is unavailable. (Manual selection through `BindingSpec` /
`ChartSpec::from_bindings` can use a non-timestamp X column, which is then
classified as an `AxisKind::Numeric` axis.)
- Columns with `ColumnKind::Unknown` are excluded from auto-detection entirely.
- `Collection`-source saved charts cannot be opened as a `ChartDocument`; they
re-open the underlying `DataDocument` in chart mode instead. Passing a
`Collection` source to `ChartDocument::from_saved` returns an error.
- `AxisSpec.unit` is always `None` in this version; drivers do not yet supply
unit metadata.
- Every `ChartSpec` constructor (`from_detection`, `from_bindings`,
`from_manual_selection`) produces a spec with `kind = ChartKind::Line`; the
other chart kinds are selected after construction.
- Series decimation uses an LTTB threshold whose default is 10,000 points
(`default_decimation_threshold`).