Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
33af223
Add fontsize aesthetic for linear text sizing
teunbrand Feb 20, 2026
e8822be
Implement TextRenderer with data-splitting for font properties
teunbrand Feb 23, 2026
b19bc75
Simplify FontStrategy by unifying single and multi-layer cases
teunbrand Feb 23, 2026
0fe62e2
Remove TextMetadata wrapper, use FontStrategy directly
teunbrand Feb 23, 2026
a4d2563
Remove unused signature field from FontGroup
teunbrand Feb 23, 2026
800b161
Simplify TextRenderer by using HashMap for grouping
teunbrand Feb 23, 2026
3181fb0
Remove FontStrategy wrapper struct
teunbrand Feb 23, 2026
283f941
Use HashMap<FontKey, Vec<usize>> with direct property conversion
teunbrand Feb 23, 2026
814bbed
Sort font groups once in analyze_font_columns
teunbrand Feb 23, 2026
f74170f
Use Option<Value> for family and apply clippy suggestions
teunbrand Feb 23, 2026
4a34cc2
Split non-contiguous indices to preserve z-order
teunbrand Feb 23, 2026
221ecd5
Suppress legend and scale for text encoding
teunbrand Feb 23, 2026
676a229
Refactor TextRenderer to use nested layers with shared encoding
teunbrand Feb 23, 2026
35a7d16
Add test for text renderer nested layers structure
teunbrand Feb 23, 2026
0bd4405
Unify single and nested layer logic in TextRenderer
teunbrand Feb 23, 2026
3b821f3
Add angle aesthetic to text geom
teunbrand Feb 23, 2026
76d0394
Complete angle aesthetic implementation with integration test
teunbrand Feb 24, 2026
4b3ab9b
Refactor TextRenderer to use pure run-length encoding
teunbrand Feb 24, 2026
d94c20a
Add nudge_x and nudge_y parameters to text/label geoms
teunbrand Feb 24, 2026
d970e14
Add format parameter for text label formatting
teunbrand Feb 24, 2026
1b522f7
Merge branch 'main' into text_layer
teunbrand Feb 24, 2026
6990790
soothe compiler
teunbrand Feb 24, 2026
39e4550
Handle font properties from parameters
teunbrand Feb 25, 2026
d765bf4
Refactor text geom font property handling
teunbrand Feb 25, 2026
bfbf943
specify fontsize in pt
teunbrand Feb 25, 2026
504f631
delenda est
teunbrand Feb 25, 2026
862b6ba
docs
teunbrand Feb 25, 2026
eb0824c
Merge branch 'main' into text_layer
teunbrand Mar 3, 2026
05d6bab
fix mismerged test
teunbrand Mar 4, 2026
f1fd917
fix another test expectation
teunbrand Mar 4, 2026
e8be14e
finally do something about this darn test that keeps mucking up test …
teunbrand Mar 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/syntax/index.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ There are many different layers to choose from when visualising your data. Some
- [`area`](layer/area.qmd) is used to display series as an area chart.
- [`ribbon`](layer/ribbon.qmd) is used to display series extrema.
- [`polygon`](layer/polygon.qmd) is used to display arbitrary shapes as polygons.
- [`text`](layer/text.qmd) is used to render datapoints as text.
- [`bar`](layer/bar.qmd) creates a bar chart, optionally calculating y from the number of records in each bar
- [`density`](layer/density.qmd) creates univariate kernel density estimates, showing the distribution of a variable
- [`violin`](layer/violin.qmd) displays a rotated kernel density estimate
Expand Down
132 changes: 132 additions & 0 deletions doc/syntax/layer/text.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
---
title: "Text"
---

> Layers are declared with the [`DRAW` clause](../clause/draw.qmd). Read the documentation for this clause for a thorough description of how to use it.

The text layer displays rows in the data as text. It can be used as a visualisation itself, or used to annotate a different layer.

## Aesthetics
The following aesthetics are recognised by the text layer.

### Required
* `x` Position on the x-axis.
* `y` Position on the y-axis.
* `label` The text to dislay.

### Optional
* `stroke` The colour at the contour lines of glyphs. Typically kept blank.
* `fill` The colour of the glyphs.
* `colour` Shorthand for setting `stroke` and `fill` simultaneously.
* `opacity` The opacity of the fill colour.
* `family` The typeface to style the lettering.
* `fontsize` The size of the text in points.
* `fontface` Font style, can be one of `'bold'`, `'italic'` or `'bold.italic'`.
* `hjust` Horizontal justification. Can be a numeric value between 0-1 or one of `"left"`, `"right"` or `"centre"` (default). Interpretation of numeric values is writer-dependent.
* `vjust` Vertical justification. Can be a numeric value between 0-1 or one of `"top"`, `"bottom"` or `"middle"` (default). Interpretation of numeric values is writer-dependent.
* `angle` Rotation of the text in degrees.

## Settings
* `nudge_x` Horizontal offset expressed in absolute points.
* `nudge_y` Vertical offset expressed in absolute points.
* `format` Formatting specifier, see explanation below.

### Format

The `format` setting can take a string that will be used in formatting the `label` aesthetic.
The basic syntax for this is that the `label` value will be inserted into any place where `{}` appears.
This means that e.g. `SETTING format => '{} species'` will result in the label "adelie species" for a row where the `label` value is "adelie".
Besides simply inserting the value as-is, it is also possible to apply a formatter to `label` before insertion by naming a formatter inside the curly braces prefixed with `:`.
Known formatters are:

* `{:Title}` will title-case the value (make the first letter in each work upper case) before insertion, e.g. `SETTING format => '{:Title} species'` will become "Adelie species" for the "adelie" label.
* `{:UPPER}` will make the value upper-case, e.g. `SETTING format => '{:UPPER} species'` will become "ADELIE species" for the "adelie" label.
* `{:lower}` works much like `{:UPPER}` but changes the value to lower-case instead.
* `{:time ...}` will format a date/datetime/time value according to the format defined afterwards. The formatting follows strftime format using the Rust chrono library. You can see an overview of the supported syntax at the [chrono docs](https://docs.rs/chrono/latest/chrono/format/strftime/index.html). The basic usage is `SETTING format => '{:time %B %Y}` which would format a value at 2025-07-04 as "July 2025".
* `{:num ...}` will format a numeric value according to the format defined afterwards. The format follows the printf format using the Rust sprintf library. The syntax is `%[flags][width][.precision]type` with the following meaning:
- `flags`: One or more modifiers:
* `-`: left-justify
* `+`: Force sign for positive numbers
* ` `: (space) Space before positive numbers
* `0`: Zero-pad
* `#`: Alternate form (`0x` prefix for hex, etc)
- `width`: The minimum width of characters to render. Depending on the `flags` the string will be padded to be at least this width
- `precision`: The maximum precision of the number. For `%g`/`%G` it is the total number of digits whereas for the rest it is the number of digits to the right of the decimal point
- `type`: How to present the number. One of:
* `d`/`i`: Signed decimal integers
* `u`: Unsigned decimal integers
* `f`/`F`: Decimal floating point
* `e`/`E`: Scientific notation
* `g`/`G`: Shortest form of `e` and `f`
* `o`: Unsigned octal
* `x`/`X`: Unsigned hexadecimal

## Data transformation
The text layer does not transform its data but passed it through unchanged.

## Examples

Standard drawing data points as labels.

```{ggsql}
VISUALISE bill_len AS x, bill_dep AS y FROM ggsql:penguins
DRAW text MAPPING island AS label
```

You can use the `format` setting to tweak the display of the label.

```{ggsql}
VISUALISE bill_len AS x, bill_dep AS y FROM ggsql:penguins
DRAW text
MAPPING island AS label
SETTING format => '{:UPPER}'
```

Setting font properties. Colours are typically mapped to the fill.

```{ggsql}
VISUALISE bill_len AS x, bill_dep AS y FROM ggsql:penguins
DRAW text
MAPPING
island AS label,
species AS fill,
flipper_len AS fontsize
SETTING
opacity => 0.8,
fontface => 'bold',
family => 'Times New Roman'
SCALE fontsize TO [6, 20]
```

The 'stroke' aesthetic is applied to the outline of the text.

```{ggsql}
SELECT 1 as x, 1 as y
VISUALISE x, y, 'My Label' AS label
DRAW text
SETTING fontsize => 30, stroke => 'red'
```

Labelling precomputed bars with the data value.

```{ggsql}
SELECT island, COUNT(*) AS n FROM ggsql:penguins GROUP BY island
VISUALISE island AS x, n AS y
DRAW bar
DRAW text
MAPPING n AS label
SETTING vjust => 'top', nudge_y => -11, fill => 'white'
```

If you label bars at the extreme end, you may to expand the scale to accommodate the labels.

```{ggsql}
SELECT island, COUNT(*) AS n FROM ggsql:penguins GROUP BY island
VISUALISE island AS x, n AS y
DRAW bar
DRAW text
MAPPING n AS label
SETTING vjust => 'bottom', nudge_y => 11
SCALE y FROM [0, 200]
```

108 changes: 92 additions & 16 deletions src/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,29 +179,105 @@ pub fn apply_label_template(
}
let key = elem.to_key_string();

let break_val = key.clone();
// Only apply template if no explicit mapping exists
result.entry(key).or_insert_with(|| {
let label = if placeholders.is_empty() {
// No placeholders - use template as literal string
template.to_string()
} else {
// Replace each placeholder with its transformed value
// Process in reverse order to preserve string indices
let mut label = template.to_string();
for parsed in placeholders.iter().rev() {
let transformed = apply_transformation(&break_val, &parsed.placeholder);
label = label.replace(&parsed.match_text, &transformed);
}
label
};
Some(label)
result.entry(key.clone()).or_insert_with(|| {
// Use shared format_value helper
Some(format_value(&key, template, &placeholders))
});
}

result
}

/// Apply label formatting template to a DataFrame column.
///
/// Returns a new DataFrame with the specified column formatted according to the template.
///
/// # Arguments
/// * `df` - DataFrame containing the column to format
/// * `column_name` - Name of the column to format
/// * `template` - Template string with placeholders (e.g., "{:Title}", "{:num %.2f}")
///
/// # Returns
/// New DataFrame with formatted column
///
/// # Example
/// ```ignore
/// let formatted_df = format_dataframe_column(&df, "_aesthetic_label", "Region: {:Title}")?;
/// ```
pub fn format_dataframe_column(
df: &polars::prelude::DataFrame,
column_name: &str,
template: &str,
) -> Result<polars::prelude::DataFrame, String> {
use polars::prelude::*;

// Get the column
let column = df
.column(column_name)
.map_err(|e| format!("Column '{}' not found: {}", column_name, e))?;

// Step 1: Convert entire column to strings
let string_values: Vec<Option<String>> = if let Ok(str_col) = column.str() {
// String column (includes temporal data auto-converted to ISO format)
str_col
.into_iter()
.map(|opt| opt.map(|s| s.to_string()))
.collect()
} else if let Ok(num_col) = column.cast(&DataType::Float64) {
// Numeric column - use shared format_number helper for clean integer formatting
use crate::plot::format_number;

let f64_col = num_col
.f64()
.map_err(|e| format!("Failed to cast column to f64: {}", e))?;

f64_col
.into_iter()
.map(|opt| opt.map(format_number))
.collect()
} else {
return Err(format!(
"Formatting doesn't support type {:?} in column '{}'. Try string or numeric types instead.",
column.dtype(),
column_name
));
};

// Step 2: Apply formatting template to all string values
let placeholders = parse_placeholders(template);
let formatted_values: Vec<Option<String>> = string_values
.into_iter()
.map(|opt| opt.map(|s| format_value(&s, template, &placeholders)))
.collect();

let formatted_col = Series::new(column_name.into(), formatted_values);

// Replace column in DataFrame
let mut new_df = df.clone();
new_df
.replace(column_name, formatted_col)
.map_err(|e| format!("Failed to replace column: {}", e))?;

Ok(new_df)
}

/// Format a single value using template and parsed placeholders
fn format_value(value: &str, template: &str, placeholders: &[ParsedPlaceholder]) -> String {
if placeholders.is_empty() {
// No placeholders - use template as literal string
template.to_string()
} else {
// Replace each placeholder with its transformed value
let mut result = template.to_string();
for parsed in placeholders.iter().rev() {
let transformed = apply_transformation(value, &parsed.placeholder);
result = result.replace(&parsed.match_text, &transformed);
}
result
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
1 change: 1 addition & 0 deletions src/plot/aesthetic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ pub const NON_POSITIONAL: &[&str] = &[
"label",
"family",
"fontface",
"fontsize",
"hjust",
"vjust",
];
Expand Down
31 changes: 25 additions & 6 deletions src/plot/layer/geom/label.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
//! Label geom implementation
use crate::plot::{DefaultParam, DefaultParamValue};

use super::{DefaultAesthetics, GeomTrait, GeomType};
use crate::plot::types::DefaultAestheticValue;
Expand All @@ -17,18 +18,36 @@ impl GeomTrait for Label {
defaults: &[
("pos1", DefaultAestheticValue::Required),
("pos2", DefaultAestheticValue::Required),
("label", DefaultAestheticValue::Null),
("fill", DefaultAestheticValue::Null),
("label", DefaultAestheticValue::Required),
("stroke", DefaultAestheticValue::Null),
("size", DefaultAestheticValue::Number(11.0)),
("fill", DefaultAestheticValue::String("black")),
("opacity", DefaultAestheticValue::Number(1.0)),
("family", DefaultAestheticValue::Null),
("fontface", DefaultAestheticValue::Null),
("hjust", DefaultAestheticValue::Null),
("vjust", DefaultAestheticValue::Null),
("fontsize", DefaultAestheticValue::Number(11.0)),
("fontface", DefaultAestheticValue::String("normal")),
("hjust", DefaultAestheticValue::Number(0.5)),
("vjust", DefaultAestheticValue::Number(0.5)),
("angle", DefaultAestheticValue::Number(0.0)),
],
}
}

fn default_params(&self) -> &'static [DefaultParam] {
&[
DefaultParam {
name: "nudge_x",
default: DefaultParamValue::Null,
},
DefaultParam {
name: "nudge_y",
default: DefaultParamValue::Null,
},
DefaultParam {
name: "format",
default: DefaultParamValue::Null,
},
]
}
}

impl std::fmt::Display for Label {
Expand Down
30 changes: 25 additions & 5 deletions src/plot/layer/geom/text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

use super::{DefaultAesthetics, GeomTrait, GeomType};
use crate::plot::types::DefaultAestheticValue;
use crate::plot::{DefaultParam, DefaultParamValue};

/// Text geom - text labels at positions
#[derive(Debug, Clone, Copy)]
Expand All @@ -17,17 +18,36 @@ impl GeomTrait for Text {
defaults: &[
("pos1", DefaultAestheticValue::Required),
("pos2", DefaultAestheticValue::Required),
("label", DefaultAestheticValue::Null),
("label", DefaultAestheticValue::Required),
("stroke", DefaultAestheticValue::Null),
("size", DefaultAestheticValue::Number(11.0)),
("fill", DefaultAestheticValue::String("black")),
("opacity", DefaultAestheticValue::Number(1.0)),
("family", DefaultAestheticValue::Null),
("fontface", DefaultAestheticValue::Null),
("hjust", DefaultAestheticValue::Null),
("vjust", DefaultAestheticValue::Null),
("fontsize", DefaultAestheticValue::Number(11.0)),
("fontface", DefaultAestheticValue::String("normal")),
("hjust", DefaultAestheticValue::Number(0.5)),
("vjust", DefaultAestheticValue::Number(0.5)),
("angle", DefaultAestheticValue::Number(0.0)),
],
}
}

fn default_params(&self) -> &'static [DefaultParam] {
&[
DefaultParam {
name: "nudge_x",
default: DefaultParamValue::Null,
},
DefaultParam {
name: "nudge_y",
default: DefaultParamValue::Null,
},
DefaultParam {
name: "format",
default: DefaultParamValue::Null,
},
]
}
}

impl std::fmt::Display for Text {
Expand Down
2 changes: 1 addition & 1 deletion src/plot/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,7 @@ mod tests {
let text = Geom::text().aesthetics();
assert!(text.is_supported("label"));
assert!(text.is_supported("family"));
assert_eq!(text.required(), &["pos1", "pos2"]);
assert_eq!(text.required(), &["pos1", "pos2", "label"]);

// Statistical geoms only require pos1
assert_eq!(Geom::histogram().aesthetics().required(), &["pos1"]);
Expand Down
4 changes: 4 additions & 0 deletions src/plot/scale/scale_type/continuous.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ impl ScaleTypeTrait for Continuous {
ArrayElement::Number(1.0),
ArrayElement::Number(6.0),
])),
"fontsize" => Ok(Some(vec![
ArrayElement::Number(8.0),
ArrayElement::Number(20.0),
])),
"opacity" => Ok(Some(vec![
ArrayElement::Number(0.1),
ArrayElement::Number(1.0),
Expand Down
2 changes: 1 addition & 1 deletion src/plot/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ fn time_to_iso_string(nanos: i64) -> String {
}

/// Format number for display (remove trailing zeros for integers)
fn format_number(n: f64) -> String {
pub fn format_number(n: f64) -> String {
if n.fract() == 0.0 {
format!("{:.0}", n)
} else {
Expand Down
Loading
Loading