Skip to content

Commit a1055b6

Browse files
authored
Add nrow setting to facet (#190)
1 parent 9d1b405 commit a1055b6

2 files changed

Lines changed: 239 additions & 14 deletions

File tree

doc/syntax/clause/facet.qmd

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ This clause behaves much like the `SETTINGS` clause in `DRAW`, in that it allows
3434
* `'y'`: Shared x-axis scale, independent y-axis scale
3535
* `['x', 'y']`: Independent scales for both axes
3636
* `missing`: Determines how layers behave when the faceting column is missing. It can take two values: `'repeat'` (default), and `'null'`. If `'repeat'` is set, then the layer data is repeated in each panel. If `'null'`, then such layers are only displayed if a null panel is shown, as controlled by the facet scale.
37-
* `ncol`: The number of panel columns to use when faceting by a single variable. Default is 3 when fewer than 6 categories are present, 4 when fewer than 12 categeries are present and otherwise 5. When the `BY`-clause is used to set a second faceting variable, the `ncol` setting is not allowed. There is no `nrow` setting as this is derived from the number of panels and the `ncol` setting.
37+
* `ncol`/`nrow`: The dimensions of the layout when faceting by a single variable. Only one of these can be given, as the other is derived based on the number of panels to draw. Default is 3 columns when fewer than 6 categories are present, 4 columns when fewer than 12 categories are present and otherwise 5 columns. When the `BY`-clause is used to set a second faceting variable, the `ncol` and `nrow` setting are not allowed.
3838

3939
### Facet variables as aesthetics
4040
When you apply faceting to a plot you are creating new aesthetics you can control. For 1-dimensional faceting (no `BY` clause) the aesthetic is called `panel` and for 2-dimensional faceting the aesthetics are called `row` and `column`. You can read more about these aesthetics in [their documentation](../scale/aesthetic/Z_faceting.qmd)

src/plot/facet/resolve.rs

Lines changed: 238 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ impl FacetDataContext {
5151
}
5252

5353
/// Allowed properties for wrap facets
54-
const WRAP_ALLOWED: &[&str] = &["free", "ncol", "missing"];
54+
const WRAP_ALLOWED: &[&str] = &["free", "ncol", "nrow", "missing"];
5555

5656
/// Allowed properties for grid facets
5757
const GRID_ALLOWED: &[&str] = &["free", "missing"];
@@ -117,6 +117,11 @@ pub fn resolve_properties(
117117
"Setting `ncol` is only allowed for 1 dimensional facets, not 2 dimensional facets".to_string(),
118118
);
119119
}
120+
if key == "nrow" && !is_wrap {
121+
return Err(
122+
"Setting `nrow` is only allowed for 1 dimensional facets, not 2 dimensional facets".to_string(),
123+
);
124+
}
120125
return Err(format!(
121126
"Unknown setting: '{}'. Allowed settings: {}",
122127
key,
@@ -127,7 +132,7 @@ pub fn resolve_properties(
127132

128133
// Step 2: Validate property values
129134
validate_free_property(facet, positional_names)?;
130-
validate_ncol_property(facet)?;
135+
validate_layout_properties(facet)?;
131136
validate_missing_property(facet)?;
132137

133138
// Step 3: Normalize free property to boolean vector
@@ -279,20 +284,45 @@ fn normalize_free_property(facet: &mut Facet, positional_names: &[&str]) {
279284
.insert("free".to_string(), ParameterValue::Array(bool_array));
280285
}
281286

282-
/// Validate ncol property value
283-
fn validate_ncol_property(facet: &Facet) -> Result<(), String> {
287+
/// Validate ncol and nrow properties
288+
///
289+
/// - Both must be positive integers if present
290+
/// - They are mutually exclusive (cannot both be specified)
291+
fn validate_layout_properties(facet: &Facet) -> Result<(), String> {
292+
let has_ncol = facet.properties.contains_key("ncol");
293+
let has_nrow = facet.properties.contains_key("nrow");
294+
295+
// Check mutual exclusivity first
296+
if has_ncol && has_nrow {
297+
return Err(
298+
"`ncol` and `nrow` cannot both be specified. Use one or the other.".to_string(),
299+
);
300+
}
301+
302+
// Validate ncol if present
284303
if let Some(value) = facet.properties.get("ncol") {
285304
match value {
286305
ParameterValue::Number(n) => {
287306
if *n <= 0.0 || n.fract() != 0.0 {
288307
return Err(format!("`ncol` must be a positive integer, got {}", n));
289308
}
290309
}
291-
_ => {
292-
return Err("'ncol' must be a number".to_string());
310+
_ => return Err("'ncol' must be a number".to_string()),
311+
}
312+
}
313+
314+
// Validate nrow if present
315+
if let Some(value) = facet.properties.get("nrow") {
316+
match value {
317+
ParameterValue::Number(n) => {
318+
if *n <= 0.0 || n.fract() != 0.0 {
319+
return Err(format!("`nrow` must be a positive integer, got {}", n));
320+
}
293321
}
322+
_ => return Err("'nrow' must be a number".to_string()),
294323
}
295324
}
325+
296326
Ok(())
297327
}
298328

@@ -322,13 +352,29 @@ fn apply_defaults(facet: &mut Facet, context: &FacetDataContext) {
322352
// Note: absence of 'free' property means fixed/shared scales (default)
323353
// No need to insert a default value
324354

325-
// Default ncol for wrap facets (computed from data)
326-
if facet.is_wrap() && !facet.properties.contains_key("ncol") {
327-
let default_cols = compute_default_ncol(context.num_levels);
328-
facet.properties.insert(
329-
"ncol".to_string(),
330-
ParameterValue::Number(default_cols as f64),
331-
);
355+
// Handle ncol/nrow for wrap facets
356+
if facet.is_wrap() {
357+
let has_ncol = facet.properties.contains_key("ncol");
358+
let has_nrow = facet.properties.contains_key("nrow");
359+
360+
if has_nrow && !has_ncol {
361+
// User provided nrow: compute ncol from it
362+
if let Some(ParameterValue::Number(nrow)) = facet.properties.get("nrow") {
363+
let nrow_val = *nrow as usize;
364+
let ncol = ((context.num_levels as f64) / (nrow_val as f64)).ceil() as i64;
365+
facet
366+
.properties
367+
.insert("ncol".to_string(), ParameterValue::Number(ncol as f64));
368+
facet.properties.remove("nrow");
369+
}
370+
} else if !has_ncol && !has_nrow {
371+
// Neither provided: apply default ncol
372+
let default_cols = compute_default_ncol(context.num_levels);
373+
facet.properties.insert(
374+
"ncol".to_string(),
375+
ParameterValue::Number(default_cols as f64),
376+
);
377+
}
332378
}
333379
}
334380

@@ -903,4 +949,183 @@ mod tests {
903949
assert!(err.contains("'theta'"));
904950
assert!(err.contains("'x'") || err.contains("'y'"));
905951
}
952+
953+
// ========================================
954+
// nrow Property Tests
955+
// ========================================
956+
957+
#[test]
958+
fn test_nrow_computes_ncol() {
959+
// 10 levels, nrow=2 -> ncol=5
960+
let mut facet = make_wrap_facet();
961+
facet
962+
.properties
963+
.insert("nrow".to_string(), ParameterValue::Number(2.0));
964+
965+
let context = make_context(10);
966+
resolve_properties(&mut facet, &context, CARTESIAN).unwrap();
967+
968+
// nrow should be removed and ncol computed
969+
assert!(!facet.properties.contains_key("nrow"));
970+
assert_eq!(
971+
facet.properties.get("ncol"),
972+
Some(&ParameterValue::Number(5.0))
973+
);
974+
}
975+
976+
#[test]
977+
fn test_nrow_with_remainder() {
978+
// 10 levels, nrow=3 -> ncol=ceil(10/3)=4
979+
let mut facet = make_wrap_facet();
980+
facet
981+
.properties
982+
.insert("nrow".to_string(), ParameterValue::Number(3.0));
983+
984+
let context = make_context(10);
985+
resolve_properties(&mut facet, &context, CARTESIAN).unwrap();
986+
987+
assert!(!facet.properties.contains_key("nrow"));
988+
assert_eq!(
989+
facet.properties.get("ncol"),
990+
Some(&ParameterValue::Number(4.0))
991+
);
992+
}
993+
994+
#[test]
995+
fn test_nrow_larger_than_num_levels() {
996+
// 3 levels, nrow=10 -> ncol=ceil(3/10)=1
997+
let mut facet = make_wrap_facet();
998+
facet
999+
.properties
1000+
.insert("nrow".to_string(), ParameterValue::Number(10.0));
1001+
1002+
let context = make_context(3);
1003+
resolve_properties(&mut facet, &context, CARTESIAN).unwrap();
1004+
1005+
assert!(!facet.properties.contains_key("nrow"));
1006+
assert_eq!(
1007+
facet.properties.get("ncol"),
1008+
Some(&ParameterValue::Number(1.0))
1009+
);
1010+
}
1011+
1012+
#[test]
1013+
fn test_nrow_equals_num_levels() {
1014+
// 5 levels, nrow=5 -> ncol=ceil(5/5)=1
1015+
let mut facet = make_wrap_facet();
1016+
facet
1017+
.properties
1018+
.insert("nrow".to_string(), ParameterValue::Number(5.0));
1019+
1020+
let context = make_context(5);
1021+
resolve_properties(&mut facet, &context, CARTESIAN).unwrap();
1022+
1023+
assert!(!facet.properties.contains_key("nrow"));
1024+
assert_eq!(
1025+
facet.properties.get("ncol"),
1026+
Some(&ParameterValue::Number(1.0))
1027+
);
1028+
}
1029+
1030+
#[test]
1031+
fn test_error_ncol_and_nrow_both_provided() {
1032+
let mut facet = make_wrap_facet();
1033+
facet
1034+
.properties
1035+
.insert("ncol".to_string(), ParameterValue::Number(3.0));
1036+
facet
1037+
.properties
1038+
.insert("nrow".to_string(), ParameterValue::Number(2.0));
1039+
1040+
let context = make_context(10);
1041+
let result = resolve_properties(&mut facet, &context, CARTESIAN);
1042+
1043+
assert!(result.is_err());
1044+
let err = result.unwrap_err();
1045+
assert!(err.contains("ncol"));
1046+
assert!(err.contains("nrow"));
1047+
assert!(err.contains("cannot both be specified"));
1048+
}
1049+
1050+
#[test]
1051+
fn test_error_negative_nrow() {
1052+
let mut facet = make_wrap_facet();
1053+
facet
1054+
.properties
1055+
.insert("nrow".to_string(), ParameterValue::Number(-1.0));
1056+
1057+
let context = make_context(5);
1058+
let result = resolve_properties(&mut facet, &context, CARTESIAN);
1059+
1060+
assert!(result.is_err());
1061+
let err = result.unwrap_err();
1062+
assert!(err.contains("nrow"));
1063+
assert!(err.contains("positive"));
1064+
}
1065+
1066+
#[test]
1067+
fn test_error_non_integer_nrow() {
1068+
let mut facet = make_wrap_facet();
1069+
facet
1070+
.properties
1071+
.insert("nrow".to_string(), ParameterValue::Number(2.5));
1072+
1073+
let context = make_context(5);
1074+
let result = resolve_properties(&mut facet, &context, CARTESIAN);
1075+
1076+
assert!(result.is_err());
1077+
let err = result.unwrap_err();
1078+
assert!(err.contains("nrow"));
1079+
assert!(err.contains("integer"));
1080+
}
1081+
1082+
#[test]
1083+
fn test_error_nrow_on_grid() {
1084+
let mut facet = make_grid_facet();
1085+
facet
1086+
.properties
1087+
.insert("nrow".to_string(), ParameterValue::Number(2.0));
1088+
1089+
let context = make_context(10);
1090+
let result = resolve_properties(&mut facet, &context, CARTESIAN);
1091+
1092+
assert!(result.is_err());
1093+
let err = result.unwrap_err();
1094+
assert!(err.contains("nrow"));
1095+
assert!(err.contains("1 dimensional"));
1096+
}
1097+
1098+
#[test]
1099+
fn test_nrow_not_string() {
1100+
let mut facet = make_wrap_facet();
1101+
facet
1102+
.properties
1103+
.insert("nrow".to_string(), ParameterValue::String("2".to_string()));
1104+
1105+
let context = make_context(5);
1106+
let result = resolve_properties(&mut facet, &context, CARTESIAN);
1107+
1108+
assert!(result.is_err());
1109+
let err = result.unwrap_err();
1110+
assert!(err.contains("nrow"));
1111+
assert!(err.contains("number"));
1112+
}
1113+
1114+
#[test]
1115+
fn test_user_ncol_preserved() {
1116+
// Existing behavior: user-provided ncol should be preserved
1117+
let mut facet = make_wrap_facet();
1118+
facet
1119+
.properties
1120+
.insert("ncol".to_string(), ParameterValue::Number(2.0));
1121+
1122+
let context = make_context(10);
1123+
resolve_properties(&mut facet, &context, CARTESIAN).unwrap();
1124+
1125+
// User's ncol should be preserved, not overwritten
1126+
assert_eq!(
1127+
facet.properties.get("ncol"),
1128+
Some(&ParameterValue::Number(2.0))
1129+
);
1130+
}
9061131
}

0 commit comments

Comments
 (0)