Repository: jtablesaw/tablesaw
Pull request #936 (2021-05-17 04:02:06)

Add support for violin plot
Thanks for contributing.

- [x] Tick to sign-off your agreement to the [Developer Certificate of Origin (DCO) 1.1](https://developercertificate.org)

## Description
This is the fix for issue #509  

Two files are added: `ViolinPlot.java` and `ViolinTrace.java`. Also I modified the file `trace_template.html` so that violin plot can draw the inner box plot at the same time. Also, considering that the mean line of violin plot is also frequently used, it is also added into the `trace_template.html` but I comment the code (`context.put("meanLine", "{visible: true}")`) that draw the mean line in `ViolinTrace.java` because it doesn't perform well together with inner box plot. You can changed the code and style according to your preference.

## Testing

Test is developed following the style of other plot tests.



Patch:
diff --git a/core/src/main/java/tech/tablesaw/columns/numbers/DoubleParser.java b/core/src/main/java/tech/tablesaw/columns/numbers/DoubleParser.java
index f6ac951a..4eb74b3d 100644
--- a/core/src/main/java/tech/tablesaw/columns/numbers/DoubleParser.java
+++ b/core/src/main/java/tech/tablesaw/columns/numbers/DoubleParser.java
@@ -1,6 +1,8 @@
 package tech.tablesaw.columns.numbers;
 
 import com.google.common.collect.Lists;
+import java.text.NumberFormat;
+import java.text.ParseException;
 import tech.tablesaw.api.ColumnType;
 import tech.tablesaw.columns.AbstractColumnParser;
 import tech.tablesaw.io.ReadOptions;
@@ -24,9 +26,14 @@ public class DoubleParser extends AbstractColumnParser<Double> {
       return true;
     }
     try {
-      Double.parseDouble(AbstractColumnParser.remove(s, ','));
+      if (isPercent(AbstractColumnParser.remove(s, ','))) {
+        s = AbstractColumnParser.remove(s, ',');
+        Number number = NumberFormat.getPercentInstance().parse(s);
+      } else {
+        Double.parseDouble(AbstractColumnParser.remove(s, ','));
+      }
       return true;
-    } catch (NumberFormatException e) {
+    } catch (NumberFormatException | ParseException e) {
       // it's all part of the plan
       return false;
     }
@@ -42,6 +49,14 @@ public class DoubleParser extends AbstractColumnParser<Double> {
     if (isMissing(s)) {
       return DoubleColumnType.missingValueIndicator();
     }
+    if (isPercent(AbstractColumnParser.remove(s, ','))) {
+      s = AbstractColumnParser.remove(s, ',').substring(0, s.length() - 1);
+      return Double.parseDouble(s) / 100.0;
+    }
     return Double.parseDouble(AbstractColumnParser.remove(s, ','));
   }
+
+  private boolean isPercent(String s) {
+    return s.charAt(s.length() - 1) == '%';
+  }
 }
diff --git a/core/src/main/java/tech/tablesaw/io/WriteOptions.java b/core/src/main/java/tech/tablesaw/io/WriteOptions.java
index f165d1d1..5f884641 100644
--- a/core/src/main/java/tech/tablesaw/io/WriteOptions.java
+++ b/core/src/main/java/tech/tablesaw/io/WriteOptions.java
@@ -9,8 +9,16 @@ public class WriteOptions {
 
   protected final Destination dest;
 
+  /**
+   * This value is not exposed as an actual option. It will determine whether the output stream
+   * automatically closes after the table has been output. The default value is false, and it just
+   * set true for File output.
+   */
+  protected final boolean autoClose;
+
   protected WriteOptions(Builder builder) {
     this.dest = builder.dest;
+    this.autoClose = builder.autoClose;
   }
 
   public Destination destination() {
@@ -20,6 +28,7 @@ public class WriteOptions {
   public static class Builder {
 
     protected Destination dest;
+    protected boolean autoClose = false;
 
     protected Builder(Destination dest) {
       this.dest = dest;
@@ -35,6 +44,7 @@ public class WriteOptions {
 
     protected Builder(File dest) throws IOException {
       this.dest = new Destination(dest);
+      this.autoClose = true;
     }
   }
 }
diff --git a/core/src/main/java/tech/tablesaw/io/csv/CsvWriteOptions.java b/core/src/main/java/tech/tablesaw/io/csv/CsvWriteOptions.java
index ac18ecb7..4153d07c 100644
--- a/core/src/main/java/tech/tablesaw/io/csv/CsvWriteOptions.java
+++ b/core/src/main/java/tech/tablesaw/io/csv/CsvWriteOptions.java
@@ -90,6 +90,10 @@ public class CsvWriteOptions extends WriteOptions {
     return dateFormatter;
   }
 
+  public boolean autoClose() {
+    return autoClose;
+  }
+
   public static Builder builder(Destination dest) {
     return new Builder(dest);
   }
diff --git a/core/src/main/java/tech/tablesaw/io/csv/CsvWriter.java b/core/src/main/java/tech/tablesaw/io/csv/CsvWriter.java
index c2cbfc5b..e9d362f6 100644
--- a/core/src/main/java/tech/tablesaw/io/csv/CsvWriter.java
+++ b/core/src/main/java/tech/tablesaw/io/csv/CsvWriter.java
@@ -61,7 +61,7 @@ public final class CsvWriter implements DataWriter<CsvWriteOptions> {
     } finally {
       if (csvWriter != null) {
         csvWriter.flush();
-        csvWriter.close();
+        if (options.autoClose()) csvWriter.close();
       }
     }
   }
diff --git a/core/src/main/java/tech/tablesaw/io/fixed/FixedWidthWriteOptions.java b/core/src/main/java/tech/tablesaw/io/fixed/FixedWidthWriteOptions.java
index d541789b..e0a3020f 100644
--- a/core/src/main/java/tech/tablesaw/io/fixed/FixedWidthWriteOptions.java
+++ b/core/src/main/java/tech/tablesaw/io/fixed/FixedWidthWriteOptions.java
@@ -142,6 +142,10 @@ public class FixedWidthWriteOptions extends WriteOptions {
     return normalizedNewline;
   }
 
+  public boolean autoClose() {
+    return autoClose;
+  }
+
   public static Builder builder(Destination destination) throws IOException {
     return new Builder(destination);
   }
@@ -217,7 +221,7 @@ public class FixedWidthWriteOptions extends WriteOptions {
       return this;
     }
 
-    public FixedWidthWriteOptions.Builder header(FixedWidthFields columnSpecs) {
+    public FixedWidthWriteOptions.Builder columnSpecs(FixedWidthFields columnSpecs) {
       this.columnSpecs = columnSpecs;
       return this;
     }
diff --git a/core/src/main/java/tech/tablesaw/io/fixed/FixedWidthWriter.java b/core/src/main/java/tech/tablesaw/io/fixed/FixedWidthWriter.java
index 99eda589..56bce22b 100644
--- a/core/src/main/java/tech/tablesaw/io/fixed/FixedWidthWriter.java
+++ b/core/src/main/java/tech/tablesaw/io/fixed/FixedWidthWriter.java
@@ -65,7 +65,7 @@ public final class FixedWidthWriter implements DataWriter<FixedWidthWriteOptions
     } finally {
       if (fixedWidthWriter != null) {
         fixedWidthWriter.flush();
-        fixedWidthWriter.close();
+        if (options.autoClose()) fixedWidthWriter.close();
       }
     }
   }
@@ -97,6 +97,9 @@ public final class FixedWidthWriter implements DataWriter<FixedWidthWriteOptions
 
   protected FixedWidthWriterSettings fixedWidthWriterSettings(FixedWidthWriteOptions options) {
     FixedWidthWriterSettings settings = new FixedWidthWriterSettings();
+    if (options.columnSpecs() != null) {
+      settings = new FixedWidthWriterSettings(options.columnSpecs());
+    }
 
     if (options.autoConfigurationEnabled()) {
       settings.setAutoConfigurationEnabled(options.autoConfigurationEnabled());
diff --git a/core/src/main/java/tech/tablesaw/joining/DataFrameJoiner.java b/core/src/main/java/tech/tablesaw/joining/DataFrameJoiner.java
index f1bb9f7b..874a4964 100644
--- a/core/src/main/java/tech/tablesaw/joining/DataFrameJoiner.java
+++ b/core/src/main/java/tech/tablesaw/joining/DataFrameJoiner.java
@@ -80,10 +80,10 @@ public class DataFrameJoiner {
    */
   public Table inner(boolean allowDuplicateColumnNames, Table... tables) {
     Table joined = table;
-
     for (Table currT : tables) {
       joined =
-          joinInternal(joined, currT, JoinType.INNER, allowDuplicateColumnNames, joinColumnNames);
+          joinInternal(
+              joined, currT, JoinType.INNER, allowDuplicateColumnNames, false, joinColumnNames);
     }
     return joined;
   }
@@ -144,10 +144,35 @@ public class DataFrameJoiner {
    */
   public Table inner(Table table2, boolean allowDuplicateColumnNames, String... col2Names) {
     Table joinedTable;
-    joinedTable = joinInternal(table, table2, JoinType.INNER, allowDuplicateColumnNames, col2Names);
+    joinedTable =
+        joinInternal(table, table2, JoinType.INNER, allowDuplicateColumnNames, false, col2Names);
     return joinedTable;
   }
 
+  /**
+   * Joins the joiner to the table2, using the given columns for the second table and returns the
+   * resulting table
+   *
+   * @param table2 The table to join with
+   * @param allowDuplicateColumnNames if {@code false} the join will fail if any columns other than
+   *     the join column have the same name if {@code true} the join will succeed and duplicate
+   *     columns are renamed*
+   * @param keepAllJoinKeyColumns if {@code false} the join will only keep join key columns in
+   *     table1 if {@code true} the join will return all join key columns in both table, which may
+   *     have difference when there are null values
+   * @param col2Names The columns to join on. If a name refers to a double column, the join is
+   *     performed after rounding to integers.
+   * @return The resulting table
+   */
+  public Table inner(
+      Table table2,
+      boolean allowDuplicateColumnNames,
+      boolean keepAllJoinKeyColumns,
+      String... col2Names) {
+    return joinInternal(
+        table, table2, JoinType.INNER, allowDuplicateColumnNames, keepAllJoinKeyColumns, col2Names);
+  }
+
   /**
    * Joins two tables.
    *
@@ -157,6 +182,9 @@ public class DataFrameJoiner {
    * @param allowDuplicates if {@code false} the join will fail if any columns other than the join
    *     column have the same name if {@code true} the join will succeed and duplicate columns are
    *     renamed
+   * @param keepAllJoinKeyColumns if {@code false} the join will only keep join key columns in
+   *     table1 if {@code true} the join will return all join key columns in both table, which may
+   *     have difference when there are null values
    * @param table2JoinColumnNames The names of the columns in table2 to join on.
    * @return the joined table
    */
@@ -165,6 +193,7 @@ public class DataFrameJoiner {
       Table table2,
       JoinType joinType,
       boolean allowDuplicates,
+      boolean keepAllJoinKeyColumns,
       String... table2JoinColumnNames) {
 
     List<Integer> table2JoinColumnIndexes = getJoinIndexes(table2, table2JoinColumnNames);
@@ -176,13 +205,21 @@ public class DataFrameJoiner {
     // keys.
     Set<Integer> resultIgnoreColIndexes =
         emptyTableFromColumns(
-            result, table1, table2, joinType, allowDuplicates, table2JoinColumnIndexes);
+            result,
+            table1,
+            table2,
+            joinType,
+            allowDuplicates,
+            table2JoinColumnIndexes,
+            keepAllJoinKeyColumns);
 
     validateIndexes(table1Indexes, table2Indexes);
     if (table1.rowCount() == 0 && (joinType == JoinType.LEFT_OUTER || joinType == JoinType.INNER)) {
       // Handle special case of empty table here so it doesn't fall through to the behavior
       // that adds rows for full outer and right outer joins
-      result.removeColumns(Ints.toArray(resultIgnoreColIndexes));
+      if (!keepAllJoinKeyColumns) {
+        result.removeColumns(Ints.toArray(resultIgnoreColIndexes));
+      }
       return result;
     }
 
@@ -202,13 +239,21 @@ public class DataFrameJoiner {
         Selection table2Rows =
             createMultiColSelection(
                 table2, ri, table2Indexes, table2.rowCount(), table2JoinColumnIndexes);
-
-        crossProduct(result, table1, table2, table1Rows, table2Rows, resultIgnoreColIndexes);
+        crossProduct(
+            result,
+            table1,
+            table2,
+            table1Rows,
+            table2Rows,
+            resultIgnoreColIndexes,
+            keepAllJoinKeyColumns);
 
         table2DoneRows = table2DoneRows.or(table2Rows);
         if (table2DoneRows.size() == table2.rowCount()) {
           // Processed all the rows in table1 exit early.
-          result.removeColumns(Ints.toArray(resultIgnoreColIndexes));
+          if (!keepAllJoinKeyColumns) {
+            result.removeColumns(Ints.toArray(resultIgnoreColIndexes));
+          }
           return result;
         }
       }
@@ -227,18 +272,27 @@ public class DataFrameJoiner {
                 table1, ri, table2Indexes, table2.rowCount(), joinColumnIndexes);
         if ((joinType == JoinType.LEFT_OUTER || joinType == JoinType.FULL_OUTER)
             && table2Rows.isEmpty()) {
-          withMissingLeftJoin(result, table1, table1Rows, resultIgnoreColIndexes);
+          withMissingLeftJoin(
+              result, table1, table1Rows, resultIgnoreColIndexes, keepAllJoinKeyColumns);
         } else {
-          crossProduct(result, table1, table2, table1Rows, table2Rows, resultIgnoreColIndexes);
+          crossProduct(
+              result,
+              table1,
+              table2,
+              table1Rows,
+              table2Rows,
+              resultIgnoreColIndexes,
+              keepAllJoinKeyColumns);
         }
-
         table1DoneRows = table1DoneRows.or(table1Rows);
         if (joinType == JoinType.FULL_OUTER || joinType == JoinType.RIGHT_OUTER) {
           // Update done rows in table2 for full Outer.
           table2DoneRows = table2DoneRows.or(table2Rows);
         } else if (table1DoneRows.size() == table1.rowCount()) {
           // Processed all the rows in table1 exit early.
-          result.removeColumns(Ints.toArray(resultIgnoreColIndexes));
+          if (!keepAllJoinKeyColumns) {
+            result.removeColumns(Ints.toArray(resultIgnoreColIndexes));
+          }
           return result;
         }
       }
@@ -253,8 +307,11 @@ public class DataFrameJoiner {
         table2Rows,
         joinType,
         table2JoinColumnIndexes,
-        resultIgnoreColIndexes);
-    result.removeColumns(Ints.toArray(resultIgnoreColIndexes));
+        resultIgnoreColIndexes,
+        keepAllJoinKeyColumns);
+    if (!keepAllJoinKeyColumns) {
+      result.removeColumns(Ints.toArray(resultIgnoreColIndexes));
+    }
     return result;
   }
 
@@ -432,11 +489,45 @@ public class DataFrameJoiner {
     for (Table currT : tables) {
       joined =
           joinInternal(
-              joined, currT, JoinType.FULL_OUTER, allowDuplicateColumnNames, joinColumnNames);
+              joined,
+              currT,
+              JoinType.FULL_OUTER,
+              allowDuplicateColumnNames,
+              false,
+              joinColumnNames);
     }
     return joined;
   }
 
+  /**
+   * Joins the joiner to the table2, using the given columns for the second table and returns the
+   * resulting table
+   *
+   * @param table2 The table to join with
+   * @param allowDuplicateColumnNames if {@code false} the join will fail if any columns other than
+   *     the join column have the same name if {@code true} the join will succeed and duplicate
+   *     columns are renamed
+   * @param keepAllJoinKeyColumns if {@code false} the join will only keep join key columns in
+   *     table1 if {@code true} the join will return all join key columns in both table, which may
+   *     have difference when there are null values
+   * @param col2Names The columns to join on. If a name refers to a double column, the join is
+   *     performed after rounding to integers.
+   * @return The resulting table
+   */
+  public Table fullOuter(
+      Table table2,
+      boolean allowDuplicateColumnNames,
+      boolean keepAllJoinKeyColumns,
+      String... col2Names) {
+    return joinInternal(
+        table,
+        table2,
+        JoinType.FULL_OUTER,
+        allowDuplicateColumnNames,
+        keepAllJoinKeyColumns,
+        col2Names);
+  }
+
   /**
    * Full outer join the joiner to the table2, using the given column for the second table and
    * returns the resulting table
@@ -447,7 +538,7 @@ public class DataFrameJoiner {
    * @return The resulting table
    */
   public Table fullOuter(Table table2, String col2Name) {
-    return joinInternal(table, table2, JoinType.FULL_OUTER, false, col2Name);
+    return joinInternal(table, table2, JoinType.FULL_OUTER, false, false, col2Name);
   }
 
   /**
@@ -474,7 +565,12 @@ public class DataFrameJoiner {
     for (Table table2 : tables) {
       joined =
           joinInternal(
-              joined, table2, JoinType.LEFT_OUTER, allowDuplicateColumnNames, joinColumnNames);
+              joined,
+              table2,
+              JoinType.LEFT_OUTER,
+              allowDuplicateColumnNames,
+              false,
+              joinColumnNames);
     }
     return joined;
   }
@@ -518,7 +614,37 @@ public class DataFrameJoiner {
    * @return The resulting table
    */
   public Table leftOuter(Table table2, boolean allowDuplicateColumnNames, String... col2Names) {
-    return joinInternal(table, table2, JoinType.LEFT_OUTER, allowDuplicateColumnNames, col2Names);
+    return joinInternal(
+        table, table2, JoinType.LEFT_OUTER, allowDuplicateColumnNames, false, col2Names);
+  }
+
+  /**
+   * Joins the joiner to the table2, using the given columns for the second table and returns the
+   * resulting table
+   *
+   * @param table2 The table to join with
+   * @param allowDuplicateColumnNames if {@code false} the join will fail if any columns other than
+   *     the join column have the same name if {@code true} the join will succeed and duplicate
+   *     columns are renamed
+   * @param keepAllJoinKeyColumns if {@code false} the join will only keep join key columns in
+   *     table1 if {@code true} the join will return all join key columns in both table, which may
+   *     have difference when there are null values
+   * @param col2Names The columns to join on. If a name refers to a double column, the join is
+   *     performed after rounding to integers.
+   * @return The resulting table
+   */
+  public Table leftOuter(
+      Table table2,
+      boolean allowDuplicateColumnNames,
+      boolean keepAllJoinKeyColumns,
+      String... col2Names) {
+    return joinInternal(
+        table,
+        table2,
+        JoinType.LEFT_OUTER,
+        allowDuplicateColumnNames,
+        keepAllJoinKeyColumns,
+        col2Names);
   }
 
   /**
@@ -545,7 +671,12 @@ public class DataFrameJoiner {
     for (Table table2 : tables) {
       joined =
           joinInternal(
-              joined, table2, JoinType.RIGHT_OUTER, allowDuplicateColumnNames, joinColumnNames);
+              joined,
+              table2,
+              JoinType.RIGHT_OUTER,
+              allowDuplicateColumnNames,
+              false,
+              joinColumnNames);
       joinColumnIndexes.clear();
       joinColumnIndexes.addAll(getJoinIndexes(joined, joinColumnNames));
     }
@@ -591,7 +722,37 @@ public class DataFrameJoiner {
    * @return The resulting table
    */
   public Table rightOuter(Table table2, boolean allowDuplicateColumnNames, String... col2Names) {
-    return joinInternal(table, table2, JoinType.RIGHT_OUTER, allowDuplicateColumnNames, col2Names);
+    return joinInternal(
+        table, table2, JoinType.RIGHT_OUTER, allowDuplicateColumnNames, false, col2Names);
+  }
+
+  /**
+   * Joins the joiner to the table2, using the given columns for the second table and returns the
+   * resulting table
+   *
+   * @param table2 The table to join with
+   * @param allowDuplicateColumnNames if {@code false} the join will fail if any columns other than
+   *     the join column have the same name if {@code true} the join will succeed and duplicate
+   *     columns are renamed
+   * @param keepAllJoinKeyColumns if {@code false} the join will only keep join key columns in
+   *     table1 if {@code true} the join will return all join key columns in both table, which may
+   *     have difference when there are null values
+   * @param col2Names The columns to join on. If a name refers to a double column, the join is
+   *     performed after rounding to integers.
+   * @return The resulting table
+   */
+  public Table rightOuter(
+      Table table2,
+      boolean allowDuplicateColumnNames,
+      boolean keepAllJoinKeyColumns,
+      String... col2Names) {
+    return joinInternal(
+        table,
+        table2,
+        JoinType.RIGHT_OUTER,
+        allowDuplicateColumnNames,
+        keepAllJoinKeyColumns,
+        col2Names);
   }
 
   /**
@@ -617,7 +778,8 @@ public class DataFrameJoiner {
       Table table2,
       JoinType joinType,
       boolean allowDuplicates,
-      List<Integer> table2JoinColumnIndexes) {
+      List<Integer> table2JoinColumnIndexes,
+      boolean keepTable2JoinKeyColumns) {
 
     Column<?>[] cols =
         Streams.concat(table1.columns().stream(), table2.columns().stream())
@@ -632,13 +794,17 @@ public class DataFrameJoiner {
     for (int c = 0; c < cols.length; c++) {
       if (joinType == JoinType.RIGHT_OUTER) {
         if (c < table1.columnCount() && joinColumnIndexes.contains(c)) {
-          cols[c].setName("Placeholder_" + ignoreColumns.size());
+          if (!keepTable2JoinKeyColumns) {
+            cols[c].setName("Placeholder_" + ignoreColumns.size());
+          }
           ignoreColumns.add(c);
         }
       } else {
         int table2Index = c - table1.columnCount();
         if (c >= table1.columnCount() && table2JoinColumnIndexes.contains(table2Index)) {
-          cols[c].setName("Placeholder_" + ignoreColumns.size());
+          if (!keepTable2JoinKeyColumns) {
+            cols[c].setName("Placeholder_" + ignoreColumns.size());
+          }
           ignoreColumns.add(c);
         }
       }
@@ -683,9 +849,10 @@ public class DataFrameJoiner {
       Table table2,
       Selection table1Rows,
       Selection table2Rows,
-      Set<Integer> ignoreColumns) {
+      Set<Integer> ignoreColumns,
+      boolean keepTable2JoinKeyColumns) {
     for (int c = 0; c < table1.columnCount() + table2.columnCount(); c++) {
-      if (ignoreColumns.contains(c)) {
+      if (!keepTable2JoinKeyColumns && ignoreColumns.contains(c)) {
         continue;
       }
       int table2Index = c - table1.columnCount();
@@ -709,9 +876,13 @@ public class DataFrameJoiner {
    */
   @SuppressWarnings({"rawtypes", "unchecked"})
   private void withMissingLeftJoin(
-      Table destination, Table table1, Selection table1Rows, Set<Integer> ignoreColumns) {
+      Table destination,
+      Table table1,
+      Selection table1Rows,
+      Set<Integer> ignoreColumns,
+      boolean keepTable2JoinKeyColumns) {
     for (int c = 0; c < destination.columnCount(); c++) {
-      if (ignoreColumns.contains(c)) {
+      if (!keepTable2JoinKeyColumns && ignoreColumns.contains(c)) {
         continue;
       }
       if (c < table1.columnCount()) {
@@ -739,7 +910,8 @@ public class DataFrameJoiner {
       Selection table2Rows,
       JoinType joinType,
       List<Integer> col2Indexes,
-      Set<Integer> ignoreColumns) {
+      Set<Integer> ignoreColumns,
+      boolean keepTable2JoinKeyColumns) {
 
     // Add index data from table2 into join column positions in table one.
     if (joinType == JoinType.FULL_OUTER) {
@@ -752,8 +924,10 @@ public class DataFrameJoiner {
     }
 
     for (int c = 0; c < destination.columnCount(); c++) {
-      if (ignoreColumns.contains(c) || joinColumnIndexes.contains(c)) {
-        continue;
+      if (!keepTable2JoinKeyColumns) {
+        if (ignoreColumns.contains(c) || joinColumnIndexes.contains(c)) {
+          continue;
+        }
       }
       if (c < table1ColCount) {
         for (int r1 = 0; r1 < table2Rows.size(); r1++) {
diff --git a/data/currency_percent.csv b/data/currency_percent.csv
new file mode 100644
index 00000000..ceddacb5
--- /dev/null
+++ b/data/currency_percent.csv
@@ -0,0 +1,4 @@
+Currency,Percentage,double
+$10.3,1.32%,13.14
+$50.10,32.768%,35
+$10,100%,93.131
\ No newline at end of file
diff --git a/jsplot/src/main/java/tech/tablesaw/plotly/Plot.java b/jsplot/src/main/java/tech/tablesaw/plotly/Plot.java
index c107e0b0..8bcb969e 100644
--- a/jsplot/src/main/java/tech/tablesaw/plotly/Plot.java
+++ b/jsplot/src/main/java/tech/tablesaw/plotly/Plot.java
@@ -21,10 +21,10 @@ import tech.tablesaw.plotly.display.Browser;
  */
 public class Plot {
 
-  private static final String DEFAULT_DIV_NAME = "target";
-  private static final String DEFAULT_OUTPUT_FILE = "output.html";
-  private static final String DEFAULT_OUTPUT_FILE_NAME = "output";
-  private static final String DEFAULT_OUTPUT_FOLDER = "testoutput";
+  protected static final String DEFAULT_DIV_NAME = "target";
+  protected static final String DEFAULT_OUTPUT_FILE = "output.html";
+  protected static final String DEFAULT_OUTPUT_FILE_NAME = "output";
+  protected static final String DEFAULT_OUTPUT_FOLDER = "testoutput";
 
   public static void show(Figure figure, String divName, File outputFile) {
     Page page = Page.pageBuilder(figure, divName).build();
@@ -41,6 +41,26 @@ public class Plot {
     }
   }
 
+  /**
+   * Opens the default browser on the given HTML page. This is a convenience method for anyone who
+   * wants total control over the HTML file containing one or more plots, but still wants to use the
+   * mechanism for opening the default browser on it.
+   *
+   * @param html An arbitrary HTML page, it doesn't even need plots
+   * @param outputFile The file where the page will be written
+   */
+  public static void show(String html, File outputFile) {
+    try {
+      try (Writer writer =
+          new OutputStreamWriter(new FileOutputStream(outputFile), StandardCharsets.UTF_8)) {
+        writer.write(html);
+      }
+      new Browser().browse(outputFile);
+    } catch (IOException e) {
+      throw new UncheckedIOException(e);
+    }
+  }
+
   public static void show(Figure figure, String divName) {
     show(figure, divName, defaultFile());
   }
diff --git a/jsplot/src/main/java/tech/tablesaw/plotly/api/ContourPlot.java b/jsplot/src/main/java/tech/tablesaw/plotly/api/ContourPlot.java
new file mode 100644
index 00000000..7f52b6c3
--- /dev/null
+++ b/jsplot/src/main/java/tech/tablesaw/plotly/api/ContourPlot.java
@@ -0,0 +1,32 @@
+package tech.tablesaw.plotly.api;
+
+import tech.tablesaw.api.Table;
+import tech.tablesaw.columns.Column;
+import tech.tablesaw.plotly.components.Figure;
+import tech.tablesaw.plotly.components.Layout;
+import tech.tablesaw.plotly.traces.ContourTrace;
+import tech.tablesaw.util.DoubleArrays;
+
+import java.util.List;
+
+public class ContourPlot {
+
+  private ContourPlot() {
+  }
+
+  public static Figure create(String title, Table table, String categoryCol1, String categoryCol2) {
+    Layout layout = Layout.builder(title).build();
+
+    Table counts = table.xTabCounts(categoryCol1, categoryCol2);
+    counts = counts.dropRows(counts.rowCount() - 1);
+    List<Column<?>> columns = counts.columns();
+    columns.remove(counts.columnCount() - 1);
+    Column<?> yColumn = columns.remove(0);
+    double[][] z = DoubleArrays.to2dArray(counts.numericColumns());
+
+    Object[] x = counts.columnNames().toArray();
+    Object[] y = yColumn.asObjectArray();
+    ContourTrace trace = ContourTrace.builder(x, y, z).build();
+    return new Figure(layout, trace);
+  }
+}
diff --git a/jsplot/src/main/java/tech/tablesaw/plotly/api/ViolinPlot.java b/jsplot/src/main/java/tech/tablesaw/plotly/api/ViolinPlot.java
new file mode 100644
index 00000000..75d7901d
--- /dev/null
+++ b/jsplot/src/main/java/tech/tablesaw/plotly/api/ViolinPlot.java
@@ -0,0 +1,24 @@
+package tech.tablesaw.plotly.api;
+
+import tech.tablesaw.api.Table;
+import tech.tablesaw.plotly.components.Figure;
+import tech.tablesaw.plotly.components.Layout;
+import tech.tablesaw.plotly.traces.BoxTrace;
+import tech.tablesaw.plotly.traces.ViolinTrace;
+
+public class ViolinPlot {
+
+    private static final int HEIGHT = 600;
+    private static final int WIDTH = 800;
+
+    public static Figure create(
+            String title, Table table, String groupingColumn, String numericColumn, boolean showBoxPlot, boolean showMeanLine) {
+        Layout layout = Layout.builder().title(title).height(HEIGHT).width(WIDTH).build();
+        ViolinTrace trace =
+                ViolinTrace.builder(table.categoricalColumn(groupingColumn), table.nCol(numericColumn))
+                        .boxPlot(showBoxPlot)
+                        .meanLine(showMeanLine)
+                        .build();
+        return new Figure(layout, trace);
+    }
+}
diff --git a/jsplot/src/main/java/tech/tablesaw/plotly/traces/ContourTrace.java b/jsplot/src/main/java/tech/tablesaw/plotly/traces/ContourTrace.java
new file mode 100644
index 00000000..aebee67c
--- /dev/null
+++ b/jsplot/src/main/java/tech/tablesaw/plotly/traces/ContourTrace.java
@@ -0,0 +1,95 @@
+package tech.tablesaw.plotly.traces;
+
+import com.mitchellbosecke.pebble.error.PebbleException;
+import com.mitchellbosecke.pebble.template.PebbleTemplate;
+
+import java.io.IOException;
+import java.io.StringWriter;
+import java.io.UncheckedIOException;
+import java.io.Writer;
+import java.util.Map;
+
+import static tech.tablesaw.plotly.Utils.dataAsString;
+
+public class ContourTrace extends AbstractTrace {
+
+  private final Object[] x;
+  private final Object[] y;
+  private final double[][] z;
+  private final String type;
+
+  public ContourTrace(ContourBuilder builder) {
+    super(builder);
+    this.x = builder.x;
+    this.y = builder.y;
+    this.z = builder.z;
+    this.type = builder.getType();
+  }
+
+  @Override
+  public String asJavascript(int i) {
+    Writer writer = new StringWriter();
+    PebbleTemplate compiledTemplate;
+
+    try {
+      compiledTemplate = engine.getTemplate("trace_template.html");
+      compiledTemplate.evaluate(writer, getContext());
+    } catch (PebbleException e) {
+      throw new IllegalStateException(e);
+    } catch (IOException e) {
+      throw new UncheckedIOException(e);
+    }
+    return writer.toString();
+  }
+
+  @Override
+  protected Map<String, Object> getContext() {
+
+    Map<String, Object> context = super.getContext();
+    context.put("variableName", "trace0");
+    context.put("x", dataAsString(x));
+    context.put("y", dataAsString(y));
+    context.put("z", dataAsString(z));
+    context.put("type", type);
+    return context;
+  }
+
+  public static ContourTrace.ContourBuilder builder(Object[] x, Object[] y, double[][] z) {
+    return new ContourTrace.ContourBuilder(x, y, z);
+  }
+
+  public static class ContourBuilder extends TraceBuilder {
+
+    private static final String type = "contour";
+    private final Object[] x;
+    private final Object[] y;
+    private final double[][] z;
+
+    ContourBuilder(Object[] x, Object[] y, double[][] z) {
+      this.x = x;
+      this.y = y;
+      this.z = z;
+    }
+
+    @Override
+    public ContourTrace.ContourBuilder xAxis(String xAxis) {
+      super.xAxis(xAxis);
+      return this;
+    }
+
+    @Override
+    public ContourTrace.ContourBuilder yAxis(String yAxis) {
+      super.yAxis(yAxis);
+      return this;
+    }
+
+    public ContourTrace build() {
+      return new ContourTrace(this);
+    }
+
+    @Override
+    protected String getType() {
+      return type;
+    }
+  }
+}
diff --git a/jsplot/src/main/java/tech/tablesaw/plotly/traces/ViolinTrace.java b/jsplot/src/main/java/tech/tablesaw/plotly/traces/ViolinTrace.java
new file mode 100644
index 00000000..a9a545bf
--- /dev/null
+++ b/jsplot/src/main/java/tech/tablesaw/plotly/traces/ViolinTrace.java
@@ -0,0 +1,132 @@
+package tech.tablesaw.plotly.traces;
+
+import static tech.tablesaw.plotly.Utils.dataAsString;
+
+import com.mitchellbosecke.pebble.error.PebbleException;
+import com.mitchellbosecke.pebble.template.PebbleTemplate;
+import java.io.IOException;
+import java.io.StringWriter;
+import java.io.UncheckedIOException;
+import java.io.Writer;
+import java.util.Map;
+import tech.tablesaw.api.CategoricalColumn;
+import tech.tablesaw.api.NumericColumn;
+
+public class ViolinTrace extends AbstractTrace {
+
+    private final Object[] x;
+    private final double[] y;
+    private final boolean showBoxPlot;
+    private final boolean showMeanLine;
+
+    private ViolinTrace(ViolinBuilder builder) {
+        super(builder);
+        this.x = builder.x;
+        this.y = builder.y;
+        this.showMeanLine = builder.showMeanLine;
+        this.showBoxPlot = builder.showBoxPlot;
+    }
+
+    public static ViolinBuilder builder(Object[] x, double[] y) {
+        return new ViolinBuilder(x, y);
+    }
+
+    public static ViolinBuilder builder(CategoricalColumn<?> x, NumericColumn<? extends Number> y) {
+        return new ViolinBuilder(x, y);
+    }
+
+    public static ViolinBuilder builder(double[] x, double[] y) {
+        Double[] xObjs = new Double[x.length];
+        for (int i = 0; i < x.length; i++) {
+            xObjs[i] = x[i];
+        }
+        return new ViolinBuilder(xObjs, y);
+    }
+
+    @Override
+    public String asJavascript(int i) {
+        Writer writer = new StringWriter();
+        PebbleTemplate compiledTemplate;
+
+        try {
+            compiledTemplate = engine.getTemplate("trace_template.html");
+            compiledTemplate.evaluate(writer, getContext(i));
+        } catch (PebbleException e) {
+            throw new IllegalStateException(e);
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+        return writer.toString();
+    }
+
+    private Map<String, Object> getContext(int i) {
+
+        Map<String, Object> context = super.getContext();
+        context.put("variableName", "trace" + i);
+        context.put("y", dataAsString(y));
+        context.put("x", dataAsString(x));
+        if (showBoxPlot){
+            context.put("box", "{visible: true}");
+        }
+        if (showMeanLine){
+            context.put("meanLine", "{visible: true}");
+        }
+        return context;
+    }
+
+    public static class ViolinBuilder extends TraceBuilder {
+
+        private static final String type = "violin";
+        private final Object[] x;
+        private final double[] y;
+        private boolean showBoxPlot;
+        private boolean showMeanLine;
+
+        ViolinBuilder(Object[] x, double[] y) {
+            this.x = x;
+            this.y = y;
+        }
+
+        @Override
+        public ViolinBuilder name(String name) {
+            super.name(name);
+            return this;
+        }
+
+        ViolinBuilder(CategoricalColumn<?> x, NumericColumn<? extends Number> y) {
+            this.x = columnToStringArray(x);
+            this.y = y.asDoubleArray();
+        }
+
+        public ViolinBuilder boxPlot(boolean show) {
+            this.showBoxPlot = show;
+            return this;
+        }
+
+        public ViolinBuilder meanLine(boolean show) {
+            this.showMeanLine = show;
+            return this;
+        }
+
+        public ViolinTrace build() {
+            return new ViolinTrace(this);
+        }
+
+        @Override
+        public ViolinBuilder xAxis(String xAxis) {
+            super.xAxis(xAxis);
+            return this;
+        }
+
+        @Override
+        public ViolinBuilder yAxis(String yAxis) {
+            super.yAxis(yAxis);
+            return this;
+        }
+
+        @Override
+        protected String getType() {
+            return type;
+        }
+    }
+}
diff --git a/jsplot/src/main/resources/trace_template.html b/jsplot/src/main/resources/trace_template.html
index 703c7f72..f113c600 100644
--- a/jsplot/src/main/resources/trace_template.html
+++ b/jsplot/src/main/resources/trace_template.html
@@ -86,4 +86,10 @@ fillcolor: '{{fillColor}}',
 {% endif %}
 type: '{{type}}',
 name: '{{name}}',
+{% if box is not null %}
+box: {{box}},
+{% endif %}
+{% if meanLine is not null %}
+meanline: {{meanLine}},
+{% endif %}
 };
\ No newline at end of file


Test patch:
diff --git a/core/src/test/java/tech/tablesaw/api/DoubleParserTest.java b/core/src/test/java/tech/tablesaw/api/DoubleParserTest.java
new file mode 100644
index 00000000..e6032a28
--- /dev/null
+++ b/core/src/test/java/tech/tablesaw/api/DoubleParserTest.java
@@ -0,0 +1,52 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package tech.tablesaw.api;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.junit.jupiter.api.Test;
+import tech.tablesaw.columns.numbers.DoubleParser;
+
+class DoubleParserTest {
+  @Test
+  void testCanParse1() {
+    DoubleParser doubleParser = new DoubleParser(ColumnType.DOUBLE);
+    assertTrue(doubleParser.canParse("1.3"));
+  }
+
+  @Test
+  void testCanParse2() {
+    DoubleParser doubleParser = new DoubleParser(ColumnType.DOUBLE);
+    assertTrue(doubleParser.canParse("1.3%"));
+  }
+
+  @Test
+  void testCanParse3() {
+    DoubleParser doubleParser = new DoubleParser(ColumnType.DOUBLE);
+    assertFalse(doubleParser.canParse("%"));
+  }
+
+  @Test
+  void testParseDouble1() {
+    DoubleParser doubleParser = new DoubleParser(ColumnType.DOUBLE);
+    assertEquals(1.3, doubleParser.parseDouble("1.3"));
+  }
+
+  @Test
+  void testParseDouble2() {
+    DoubleParser doubleParser = new DoubleParser(ColumnType.DOUBLE);
+    assertEquals(0.012, doubleParser.parseDouble("1.2%"));
+  }
+}
diff --git a/core/src/test/java/tech/tablesaw/io/DataFrameWriterTest.java b/core/src/test/java/tech/tablesaw/io/DataFrameWriterTest.java
index 77bbb21b..c52c76e5 100644
--- a/core/src/test/java/tech/tablesaw/io/DataFrameWriterTest.java
+++ b/core/src/test/java/tech/tablesaw/io/DataFrameWriterTest.java
@@ -2,9 +2,12 @@ package tech.tablesaw.io;
 
 import static java.lang.Double.NaN;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.fail;
 
-import java.io.ByteArrayOutputStream;
-import java.io.OutputStreamWriter;
+import java.io.*;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
 import org.junit.jupiter.api.Test;
 import tech.tablesaw.api.DoubleColumn;
 import tech.tablesaw.api.Table;
@@ -38,4 +41,74 @@ public class DataFrameWriterTest {
         "v,v2" + LINE_END + "1.0,1.0" + LINE_END + "2.0,2.0" + LINE_END + "," + LINE_END + "",
         output);
   }
+
+  @Test
+  public void testFileOutputStreamWhetherClose() throws IOException {
+    // Create directory if it doesn't exist
+    String DEFAULT_OUTPUT_FOLDER = "../testoutput";
+    Path path = Paths.get(DEFAULT_OUTPUT_FOLDER, "testOutput.csv");
+    try {
+      Files.createDirectories(path.getParent());
+    } catch (IOException e) {
+      throw new UncheckedIOException(e);
+    }
+
+    File file = path.toFile();
+    FileOutputStream fos = new FileOutputStream(file);
+    table.write().csv(fos);
+
+    // Read file content
+    FileInputStream fis = new FileInputStream(file);
+    byte[] filecontent = new byte[(int) file.length()];
+    fis.read(filecontent);
+    String output = new String(filecontent);
+
+    assertEquals(
+        "v,v2" + LINE_END + "1.0,1.0" + LINE_END + "2.0,2.0" + LINE_END + "," + LINE_END + "",
+        output);
+
+    // cannot access the status of fos, so write again to test whether it close
+    try {
+      table.write().csv(fos);
+
+      fis = new FileInputStream(file);
+      filecontent = new byte[(int) file.length()];
+      fis.read(filecontent);
+      output = new String(filecontent);
+
+      assertEquals(
+          "v,v2" + LINE_END + "1.0,1.0" + LINE_END + "2.0,2.0" + LINE_END + "," + LINE_END + "v,v2"
+              + LINE_END + "1.0,1.0" + LINE_END + "2.0,2.0" + LINE_END + "," + LINE_END + "",
+          output);
+    } catch (Exception e) {
+      fail(e);
+    } finally {
+      file.delete();
+    }
+  }
+
+  @Test
+  public void testOutputStreamWriterWhetherClose() {
+    ByteArrayOutputStream baos = new ByteArrayOutputStream();
+    OutputStreamWriter osw = new OutputStreamWriter(baos);
+    table.write().csv(osw);
+    String output = baos.toString();
+
+    assertEquals(
+        "v,v2" + LINE_END + "1.0,1.0" + LINE_END + "2.0,2.0" + LINE_END + "," + LINE_END + "",
+        output);
+
+    // cannot access the status of osw directly, so write again to test whether it close
+    try {
+      table.write().csv(osw);
+      output = baos.toString();
+
+      assertEquals(
+          "v,v2" + LINE_END + "1.0,1.0" + LINE_END + "2.0,2.0" + LINE_END + "," + LINE_END + "v,v2"
+              + LINE_END + "1.0,1.0" + LINE_END + "2.0,2.0" + LINE_END + "," + LINE_END + "",
+          output);
+    } catch (Exception e) {
+      fail(e);
+    }
+  }
 }
diff --git a/core/src/test/java/tech/tablesaw/io/csv/CsvReaderTest.java b/core/src/test/java/tech/tablesaw/io/csv/CsvReaderTest.java
index 4327f4fa..a4bef783 100644
--- a/core/src/test/java/tech/tablesaw/io/csv/CsvReaderTest.java
+++ b/core/src/test/java/tech/tablesaw/io/csv/CsvReaderTest.java
@@ -49,10 +49,13 @@ import org.junit.jupiter.api.Test;
 import tech.tablesaw.api.ColumnType;
 import tech.tablesaw.api.DateColumn;
 import tech.tablesaw.api.DateTimeColumn;
+import tech.tablesaw.api.DoubleColumn;
 import tech.tablesaw.api.LongColumn;
 import tech.tablesaw.api.ShortColumn;
 import tech.tablesaw.api.StringColumn;
 import tech.tablesaw.api.Table;
+import tech.tablesaw.columns.numbers.DoubleColumnType;
+import tech.tablesaw.columns.numbers.NumberColumnFormatter;
 import tech.tablesaw.io.AddCellToColumnException;
 
 /** Tests for CSV Reading */
@@ -846,6 +849,26 @@ public class CsvReaderTest {
     assertEquals(table.get(0, 0), out.get(0, 0));
   }
 
+  @Test
+  public void testReadCsvWithPercentage1() throws IOException {
+    Table table = Table.read().csv(CsvReadOptions.builder("../data/currency_percent.csv"));
+    assertEquals(DoubleColumnType.instance(), table.columnTypes()[1]);
+    assertEquals(DoubleColumnType.instance(), table.columnTypes()[2]);
+  }
+
+  @Test
+  public void testReadCsvWithPercentage2() throws IOException {
+    Table table = Table.read().csv(CsvReadOptions.builder("../data/currency_percent.csv"));
+    DoubleColumn column = (DoubleColumn) table.column(1);
+    assertEquals("0.0132", column.getString(0));
+    assertEquals("0.32768", column.getString(1));
+    assertEquals("1", column.getString(2));
+    column.setPrintFormatter(NumberColumnFormatter.percent(2));
+    assertEquals("1.32%", column.getString(0));
+    assertEquals("32.77%", column.getString(1));
+    assertEquals("100.00%", column.getString(2));
+  }
+
   @Test
   public void testSkipRowsWithInvalidColumnCount() throws IOException {
     Table table =
diff --git a/core/src/test/java/tech/tablesaw/io/fixed/FixedWidthWriterTest.java b/core/src/test/java/tech/tablesaw/io/fixed/FixedWidthWriterTest.java
new file mode 100644
index 00000000..3f3a2c8d
--- /dev/null
+++ b/core/src/test/java/tech/tablesaw/io/fixed/FixedWidthWriterTest.java
@@ -0,0 +1,185 @@
+package tech.tablesaw.io.fixed;
+
+import static java.lang.Double.NaN;
+import static org.junit.jupiter.api.Assertions.*;
+
+import com.univocity.parsers.fixed.FixedWidthFields;
+import java.io.*;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import org.junit.jupiter.api.Test;
+import tech.tablesaw.api.DoubleColumn;
+import tech.tablesaw.api.Table;
+
+public class FixedWidthWriterTest {
+  private static final String LINE_END = System.lineSeparator();
+
+  private double[] v1 = {1, 2, NaN};
+  private double[] v2 = {1, 2, NaN};
+  private Table table =
+      Table.create("t", DoubleColumn.create("v1", v1), DoubleColumn.create("v2", v2));
+
+  @Test
+  public void testOutputInFixedWidthFormat() {
+    ByteArrayOutputStream baos = new ByteArrayOutputStream();
+    FixedWidthFields fwf = new FixedWidthFields(10, 10);
+    FixedWidthWriteOptions options =
+        new FixedWidthWriteOptions.Builder(baos)
+            .header(true)
+            .columnSpecs(fwf)
+            .autoConfigurationEnabled(false)
+            .build();
+    FixedWidthWriter writer = new FixedWidthWriter();
+    writer.write(table, options);
+
+    String output = baos.toString();
+    assertEquals(
+        "v1________v2________"
+            + LINE_END
+            + "1.0_______1.0_______"
+            + LINE_END
+            + "2.0_______2.0_______"
+            + LINE_END
+            + "____________________"
+            + LINE_END
+            + "",
+        output);
+  }
+
+  @Test
+  public void testFileOutputStreamWhetherClose() throws IOException {
+    // Create directory if it doesn't exist
+    String DEFAULT_OUTPUT_FOLDER = "../testoutput";
+    Path path = Paths.get(DEFAULT_OUTPUT_FOLDER, "testOutput.txt");
+    try {
+      Files.createDirectories(path.getParent());
+    } catch (IOException e) {
+      throw new UncheckedIOException(e);
+    }
+
+    File file = path.toFile();
+    FileOutputStream fos = new FileOutputStream(file);
+    FixedWidthFields fwf = new FixedWidthFields(10, 10);
+
+    FixedWidthWriteOptions options =
+        new FixedWidthWriteOptions.Builder(fos)
+            .header(true)
+            .autoConfigurationEnabled(false)
+            .columnSpecs(fwf)
+            .build();
+    FixedWidthWriter writer = new FixedWidthWriter();
+    writer.write(table, options);
+
+    // Read file content
+    FileInputStream fis = new FileInputStream(file);
+    byte[] filecontent = new byte[(int) file.length()];
+    fis.read(filecontent);
+    String output = new String(filecontent);
+
+    assertEquals(
+        "v1________v2________"
+            + LINE_END
+            + "1.0_______1.0_______"
+            + LINE_END
+            + "2.0_______2.0_______"
+            + LINE_END
+            + "____________________"
+            + LINE_END
+            + "",
+        output);
+
+    // cannot access the status of fos, so write again to test whether it close
+    try {
+      writer.write(table, options);
+
+      fis = new FileInputStream(file);
+      filecontent = new byte[(int) file.length()];
+      fis.read(filecontent);
+      output = new String(filecontent);
+
+      assertEquals(
+          "v1________v2________"
+              + LINE_END
+              + "1.0_______1.0_______"
+              + LINE_END
+              + "2.0_______2.0_______"
+              + LINE_END
+              + "____________________"
+              + LINE_END
+              + ""
+              + "v1________v2________"
+              + LINE_END
+              + "1.0_______1.0_______"
+              + LINE_END
+              + "2.0_______2.0_______"
+              + LINE_END
+              + "____________________"
+              + LINE_END
+              + "",
+          output);
+    } catch (Exception e) {
+      fail(e);
+    } finally {
+      file.delete();
+    }
+  }
+
+  @Test
+  public void testOutputStreamWriterWhetherClose() throws IOException {
+    ByteArrayOutputStream baos = new ByteArrayOutputStream();
+    OutputStreamWriter osw = new OutputStreamWriter(baos);
+    FixedWidthFields fwf = new FixedWidthFields(10, 10);
+
+    FixedWidthWriteOptions options =
+        new FixedWidthWriteOptions.Builder(osw)
+            .header(true)
+            .columnSpecs(fwf)
+            .autoConfigurationEnabled(false)
+            .build();
+    FixedWidthWriter writer = new FixedWidthWriter();
+    writer.write(table, options);
+
+    String output = baos.toString();
+    assertEquals(
+        "v1________v2________"
+            + LINE_END
+            + "1.0_______1.0_______"
+            + LINE_END
+            + "2.0_______2.0_______"
+            + LINE_END
+            + "____________________"
+            + LINE_END
+            + "",
+        output);
+
+    // cannot access the status of osw directly, so write again to test whether it close
+    try {
+      writer.write(table, options);
+      output = baos.toString();
+
+      assertEquals(
+          "v1________v2________"
+              + LINE_END
+              + "1.0_______1.0_______"
+              + LINE_END
+              + "2.0_______2.0_______"
+              + LINE_END
+              + "____________________"
+              + LINE_END
+              + ""
+              + "v1________v2________"
+              + LINE_END
+              + "1.0_______1.0_______"
+              + LINE_END
+              + "2.0_______2.0_______"
+              + LINE_END
+              + "____________________"
+              + LINE_END
+              + "",
+          output);
+    } catch (Exception e) {
+      fail(e);
+    }
+  }
+}
diff --git a/core/src/test/java/tech/tablesaw/joining/DataFrameJoinerTest.java b/core/src/test/java/tech/tablesaw/joining/DataFrameJoinerTest.java
index 73c1f671..bd271d81 100644
--- a/core/src/test/java/tech/tablesaw/joining/DataFrameJoinerTest.java
+++ b/core/src/test/java/tech/tablesaw/joining/DataFrameJoinerTest.java
@@ -1438,6 +1438,46 @@ public class DataFrameJoinerTest {
     assertEquals(9, joined.rowCount());
   }
 
+  @Test
+  public void leftOuterJoin_keepAllJoinKeyColumns() {
+    Table table1 = createANIMALHOMES();
+    Table table2 = createDOUBLEINDEXEDPEOPLENameHomeAgeMoveInDate();
+    Table joined =
+        table1.joinOn("Age", "MoveInDate").leftOuter(table2, true, true, "Age", "MoveInDate");
+    assertEquals(10, joined.columnCount());
+    assertEquals(9, joined.rowCount());
+  }
+
+  @Test
+  public void rightOuterJoin_keepAllJoinKeyColumns() {
+    Table table1 = createANIMALHOMES();
+    Table table2 = createDOUBLEINDEXEDPEOPLENameHomeAgeMoveInDate();
+    Table joined =
+        table1.joinOn("Age", "MoveInDate").rightOuter(table2, true, true, "Age", "MoveInDate");
+    assertEquals(10, joined.columnCount());
+    assertEquals(6, joined.rowCount());
+  }
+
+  @Test
+  public void fullOuter_keepAllJoinKeyColumns() {
+    Table table1 = createANIMALHOMES();
+    Table table2 = createDOUBLEINDEXEDPEOPLENameHomeAgeMoveInDate();
+    Table joined =
+        table1.joinOn("Age", "MoveInDate").fullOuter(table2, true, true, "Age", "MoveInDate");
+    assertEquals(10, joined.columnCount());
+    assertEquals(12, joined.rowCount());
+  }
+
+  @Test
+  public void innerJoin_keepAllJoinKeyColumns() {
+    Table table1 = createANIMALHOMES();
+    Table table2 = createDOUBLEINDEXEDPEOPLENameHomeAgeMoveInDate();
+    Table joined =
+        table1.joinOn("Name", "Home", "Age").inner(table2, true, true, "Name", "Home", "Age");
+    assertEquals(10, joined.columnCount());
+    assertEquals(1, joined.rowCount());
+  }
+
   @Test
   public void rightOuterJoinOnAgeMoveInDate() {
     Table table1 = createANIMALHOMES();
diff --git a/jsplot/src/test/java/tech/tablesaw/plotly/ContourTest.java b/jsplot/src/test/java/tech/tablesaw/plotly/ContourTest.java
new file mode 100644
index 00000000..f4a4e585
--- /dev/null
+++ b/jsplot/src/test/java/tech/tablesaw/plotly/ContourTest.java
@@ -0,0 +1,50 @@
+package tech.tablesaw.plotly;
+
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import tech.tablesaw.api.StringColumn;
+import tech.tablesaw.api.Table;
+import tech.tablesaw.plotly.api.ContourPlot;
+import tech.tablesaw.plotly.components.Figure;
+import tech.tablesaw.plotly.traces.ContourTrace;
+
+import java.io.IOException;
+
+@Disabled
+public class ContourTest {
+
+  private final Object[] x = {-9, -6, -5, -3, -1};
+  private final Object[] y = {0, 1, 4, 5, 7};
+  private final double[][] z = {
+      {10, 10.625, 12.5, 15.625, 20},
+      {5.625, 6.25, 8.125, 11.25, 15.625},
+      {2.5, 3.125, 5.0, 8.125, 12.5},
+      {0.625, 1.25, 3.125, 6.25, 10.625},
+      {0, 0.625, 2.5, 5.625, 10}
+  };
+
+  @Test
+  public void testAsJavascript() {
+    ContourTrace trace = ContourTrace.builder(x, y, z).build();
+    System.out.println(trace.asJavascript(1));
+  }
+
+  @Test
+  public void testContourTrace() {
+    ContourTrace trace = ContourTrace.builder(x, y, z).build();
+    Figure figure = new Figure(trace);
+    Plot.show(figure);
+  }
+
+  @Test
+  public void testContourPlot() throws IOException {
+    Table table = Table.read().csv("../data/bush.csv");
+    StringColumn yearsMonth = table.dateColumn("date").yearMonth();
+    String name = "Year and month";
+    yearsMonth.setName(name);
+    table.addColumns(yearsMonth);
+
+    Figure figure = ContourPlot.create("Polls conducted by year and month", table, name, "who");
+    Plot.show(figure);
+  }
+}
diff --git a/jsplot/src/test/java/tech/tablesaw/plotly/PlotTest.java b/jsplot/src/test/java/tech/tablesaw/plotly/PlotTest.java
new file mode 100644
index 00000000..1a13b970
--- /dev/null
+++ b/jsplot/src/test/java/tech/tablesaw/plotly/PlotTest.java
@@ -0,0 +1,19 @@
+package tech.tablesaw.plotly;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.File;
+import java.nio.file.Paths;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+
+@Disabled
+class PlotTest {
+
+  @Test
+  void show() {
+    String html = "<html><body><h1>Hello World<h1></body></html>";
+    File file = Paths.get("testoutput", "myfile.html").toFile();
+    Plot.show(html, file);
+  }
+}
diff --git a/jsplot/src/test/java/tech/tablesaw/plotly/ViolinTest.java b/jsplot/src/test/java/tech/tablesaw/plotly/ViolinTest.java
new file mode 100644
index 00000000..794fe3b8
--- /dev/null
+++ b/jsplot/src/test/java/tech/tablesaw/plotly/ViolinTest.java
@@ -0,0 +1,57 @@
+package tech.tablesaw.plotly;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import tech.tablesaw.plotly.components.Figure;
+import tech.tablesaw.plotly.traces.ViolinTrace;
+
+@Disabled
+class ViolinTest {
+
+    private final Object[] x = {
+            "sheep",
+            "cows",
+            "fish",
+            "tree sloths",
+            "sheep",
+            "cows",
+            "fish",
+            "tree sloths",
+            "sheep",
+            "cows",
+            "fish",
+            "tree sloths"
+    };
+    private final double[] y = {1, 4, 9, 16, 3, 6, 8, 8, 2, 4, 7, 11};
+
+    @Test
+    void testAsJavascriptWithBoxPlot() {
+        ViolinTrace trace = ViolinTrace.builder(x, y).boxPlot(true).build();
+        assertNotNull(trace.asJavascript(1));
+    }
+
+    @Test
+    void showWithMeanLine() {
+        ViolinTrace trace = ViolinTrace.builder(x, y).meanLine(true).build();
+        Figure figure = new Figure(trace);
+        assertNotNull(figure);
+        Plot.show(figure, "target");
+    }
+
+    @Test
+    void showWithBoxPlot() {
+        ViolinTrace trace = ViolinTrace.builder(x, y).boxPlot(true).build();
+        Figure figure = new Figure(trace);
+        assertNotNull(figure);
+        Plot.show(figure, "target");
+    }
+
+    /** Test ensures that the name() method returns a ViolinTraceBuilder as expected. */
+    @Test
+    void name() {
+        ViolinTrace trace = ViolinTrace.builder(x, y).name("my name").build();
+        assertNotNull(trace);
+    }
+}