@@ -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
5757const 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