Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 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
baca449
Merge branch 'main' into text_layer
teunbrand Mar 17, 2026
2ebf9e5
soothe clippy
teunbrand Mar 17, 2026
79dc1c1
update docs
teunbrand Mar 17, 2026
26220ee
exclude label as group aesthetic
teunbrand Mar 17, 2026
1ff9633
move label formatting to post_process method
teunbrand Mar 17, 2026
e7eec16
eradicate label layer
teunbrand Mar 17, 2026
74993a2
divorce 'fontface' into 'fontweight' and 'italic'
teunbrand Mar 17, 2026
4a1b93a
rename `nudge_x/y` to `offset_x/y`
teunbrand Mar 17, 2026
84d21b8
cargo fmt
teunbrand Mar 17, 2026
af0a389
Update doc/syntax/layer/type/text.qmd
teunbrand Mar 17, 2026
0e19b35
Combine offset_x and offset_y into single (array) offset
teunbrand Mar 17, 2026
ad84287
Merge branch 'text_layer' of https://github.com/teunbrand/ggsql into …
teunbrand Mar 17, 2026
b72bc8e
fancy approach to fontweight
teunbrand Mar 17, 2026
a5b8dd9
rename `family` to `typeface`
teunbrand Mar 17, 2026
ad4f02b
rename angle to rotation
teunbrand Mar 17, 2026
0bb99e8
cargo fmt
teunbrand Mar 17, 2026
ac3790e
soothe clippy
teunbrand Mar 17, 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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ pub enum Geom {
// Statistical geoms
Histogram, Density, Smooth, Boxplot, Violin,
// Annotation geoms
Text, Label, Segment, Arrow, Rule, Linear, ErrorBar,
Text, Segment, Arrow, Rule, Linear, ErrorBar,
}

pub enum AestheticValue {
Expand Down Expand Up @@ -1211,7 +1211,7 @@ Maps data values (columns or literals) to visual aesthetics. Syntax: `value AS a
- **Position**: `x`, `y`, `xmin`, `xmax`, `ymin`, `ymax`
- **Color**: `color`, `fill`, `stroke`, `opacity`
- **Size/Shape**: `size`, `shape`, `linetype`, `linewidth`
- **Text**: `label`, `family`, `fontface`
- **Text**: `label`, `typeface`, `fontweight`, `italic`

**Literal vs Column**:

Expand Down
5 changes: 3 additions & 2 deletions doc/ggsql.xml
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,9 @@
<item>linewidth</item>
<item>width</item>
<item>height</item>
<item>family</item>
<item>fontface</item>
<item>typeface</item>
<item>fontweight</item>
<item>italic</item>
<item>hjust</item>
<item>vjust</item>
</list>
Expand Down
1 change: 1 addition & 0 deletions doc/syntax/index.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ There are many different layers to choose from when visualising your data. Some
- [`area`](layer/type/area.qmd) is used to display series as an area chart.
- [`ribbon`](layer/type/ribbon.qmd) is used to display series extrema.
- [`polygon`](layer/type/polygon.qmd) is used to display arbitrary shapes as polygons.
- [`text`](layer/text.qmd) is used to render datapoints as text.
- [`bar`](layer/type/bar.qmd) creates a bar chart, optionally calculating y from the number of records in each bar.
- [`density`](layer/type/density.qmd) creates univariate kernel density estimates, showing the distribution of a variable.
- [`violin`](layer/type/violin.qmd) displays a rotated kernel density estimate.
Expand Down
139 changes: 139 additions & 0 deletions doc/syntax/layer/type/text.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
---
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
* Primary axis (e.g. `x`): Position along the primary axis.
* Secondary axis (e.g. `y`): Position along the secondary 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.
* `typeface` The typeface to style the lettering.
* `fontsize` The size of the text in points.
* `fontweight` Font weight. Interpretation is writer dependent. Vega-Lite converts everything to 'normal' or 'bold'. Can be one of the following:
* CSS keywords: `'thin'`, `'hairline'`, `'extra-light'`, `'ultra-light'`, `'light'`, `'normal'` (default), `'regular'`, `'lighter'`, `'medium'`, `'semi-bold'`, `'demi-bold'`, `'bold'`, `'bolder'`, `'extra-bold'`, `'ultra-bold'`, `'black'`, `'heavy'`
* Numeric values between 0-1000.
* `italic` Whether text should be italicised. Boolean value (`true` or `false`).
* `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.
* `rotation` Rotation of the text in degrees.

## Settings
* `offset` Position offset expressed in absolute points. Can be one of the following:
* a single number that applies both horizontally and vertically
* an numeric array `[h, v]` where the first number is the horizontal offset and the second number is the vertical offset.
* `format` Formatting specifier, see explanation below.
* `position`: Determines the position adjustment to use for the layer (default is `'identity'`)

### 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.

## Orientation
The text layer has no orientation. The axes are treated symmetrically.

## 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,
fontweight => 'bold',
typeface => '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', offset => [0, -11], fill => 'white'
```

If you label bars at the extreme end, you may need 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', offset => [0, 11]
SCALE y FROM [0, 200]
```

2 changes: 1 addition & 1 deletion ggsql-vscode/syntaxes/ggsql.tmLanguage.json
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@
"patterns": [
{
"name": "support.type.aesthetic.ggsql",
"match": "\\b(x|y|xmin|xmax|ymin|ymax|xend|yend|weight|color|colour|fill|stroke|opacity|size|shape|linetype|linewidth|width|height|label|family|fontface|hjust|vjust|panel|row|column)\\b"
"match": "\\b(x|y|xmin|xmax|ymin|ymax|xend|yend|weight|color|colour|fill|stroke|opacity|size|shape|linetype|linewidth|width|height|label|typeface|fontweight|italic|hjust|vjust|panel|row|column)\\b"
}
]
},
Expand Down
10 changes: 7 additions & 3 deletions src/execute/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -687,8 +687,12 @@ fn add_discrete_columns_to_partition_by(
.map(|c| c.name.as_str())
.collect();

// Get aesthetics consumed by stat transforms (if any)
// Build set of excluded aesthetics that should not trigger auto-grouping:
// - Stat-consumed aesthetics (transformed, not grouped)
// - 'label' aesthetic (text content to display, not grouping categories)
let consumed_aesthetics = layer.geom.stat_consumed_aesthetics();
let mut excluded_aesthetics: HashSet<&str> = consumed_aesthetics.iter().copied().collect();
excluded_aesthetics.insert("label");

for (aesthetic, value) in &layer.mappings.aesthetics {
// Skip positional aesthetics - these should not trigger auto-grouping.
Expand All @@ -698,8 +702,8 @@ fn add_discrete_columns_to_partition_by(
continue;
}

// Skip stat-consumed aesthetics (they're transformed, not grouped)
if consumed_aesthetics.contains(&aesthetic.as_str()) {
// Skip excluded aesthetics (stat-consumed or label)
if excluded_aesthetics.contains(aesthetic.as_str()) {
continue;
}

Expand Down
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: 0 additions & 1 deletion src/parser/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -611,7 +611,6 @@ fn parse_geom_type(text: &str) -> Result<Geom> {
"boxplot" => Ok(Geom::boxplot()),
"violin" => Ok(Geom::violin()),
"text" => Ok(Geom::text()),
"label" => Ok(Geom::label()),
"segment" => Ok(Geom::segment()),
"arrow" => Ok(Geom::arrow()),
"rule" => Ok(Geom::rule()),
Expand Down
8 changes: 5 additions & 3 deletions src/plot/aesthetic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ pub const USER_FACET_AESTHETICS: &[&str] = &["panel", "row", "column"];
/// - Color aesthetics: color, colour, fill, stroke, opacity
/// - Size/shape aesthetics: size, shape, linetype, linewidth
/// - Dimension aesthetics: width, height
/// - Text aesthetics: label, family, fontface, hjust, vjust
/// - Text aesthetics: label, typeface, fontweight, italic, hjust, vjust
pub const NON_POSITIONAL: &[&str] = &[
"color",
"colour",
Expand All @@ -72,8 +72,10 @@ pub const NON_POSITIONAL: &[&str] = &[
"width",
"height",
"label",
"family",
"fontface",
"typeface",
"fontweight",
"italic",
"fontsize",
"hjust",
"vjust",
];
Expand Down
Loading
Loading