Repository: jtablesaw/tablesaw
Pull request #944 (2021-05-22 11:19:38)

To support annotation in plot.ly javascript.
Thanks for contributing.

- [x] Tick to sign-off your agreement to the [Developer Certificate of Origin (DCO) 1.1](https://developercertificate.org)

## Description

I add basic support for annotation in plot.ly as issue #615 want. It can support most of apis in annotation. 

## Testing

Did you add a unit test?

I write 2 tests for 2 conditions in [guideline of annotation](https://plotly.com/javascript/text-and-annotations) and both of them have past. 



Patch:
diff --git a/aggregate/pom.xml b/aggregate/pom.xml
index b16fcb0c..676a42c7 100644
--- a/aggregate/pom.xml
+++ b/aggregate/pom.xml
@@ -5,7 +5,7 @@
   <parent>
     <groupId>tech.tablesaw</groupId>
     <artifactId>tablesaw-parent</artifactId>
-    <version>0.38.3-SNAPSHOT</version>
+    <version>0.38.4-SNAPSHOT</version>
   </parent>
 
   <artifactId>tablesaw-aggregate</artifactId>
@@ -16,37 +16,37 @@
     <dependency>
       <groupId>tech.tablesaw</groupId>
       <artifactId>tablesaw-core</artifactId>
-      <version>0.38.3-SNAPSHOT</version>
+      <version>0.38.4-SNAPSHOT</version>
     </dependency>
     <dependency>
       <groupId>tech.tablesaw</groupId>
       <artifactId>tablesaw-beakerx</artifactId>
-      <version>0.38.3-SNAPSHOT</version>
+      <version>0.38.4-SNAPSHOT</version>
     </dependency>
     <dependency>
       <groupId>tech.tablesaw</groupId>
       <artifactId>tablesaw-excel</artifactId>
-      <version>0.38.3-SNAPSHOT</version>
+      <version>0.38.4-SNAPSHOT</version>
     </dependency>
     <dependency>
       <groupId>tech.tablesaw</groupId>
       <artifactId>tablesaw-html</artifactId>
-      <version>0.38.3-SNAPSHOT</version>
+      <version>0.38.4-SNAPSHOT</version>
     </dependency>
     <dependency>
       <groupId>tech.tablesaw</groupId>
       <artifactId>tablesaw-json</artifactId>
-      <version>0.38.3-SNAPSHOT</version>
+      <version>0.38.4-SNAPSHOT</version>
     </dependency>
     <dependency>
       <groupId>tech.tablesaw</groupId>
       <artifactId>tablesaw-jsplot</artifactId>
-      <version>0.38.3-SNAPSHOT</version>
+      <version>0.38.4-SNAPSHOT</version>
     </dependency>
     <dependency>
       <groupId>tech.tablesaw</groupId>
       <artifactId>tablesaw-saw</artifactId>
-      <version>0.38.3-SNAPSHOT</version>
+      <version>0.38.4-SNAPSHOT</version>
     </dependency>
   </dependencies>
 </project>
diff --git a/beakerx/pom.xml b/beakerx/pom.xml
index 59ef5b4a..0edc2ad3 100644
--- a/beakerx/pom.xml
+++ b/beakerx/pom.xml
@@ -5,7 +5,7 @@
   <parent>
     <groupId>tech.tablesaw</groupId>
     <artifactId>tablesaw-parent</artifactId>
-    <version>0.38.3-SNAPSHOT</version>
+    <version>0.38.4-SNAPSHOT</version>
   </parent>
 
   <artifactId>tablesaw-beakerx</artifactId>
@@ -71,12 +71,12 @@
     <dependency>
       <groupId>tech.tablesaw</groupId>
       <artifactId>tablesaw-core</artifactId>
-      <version>0.38.3-SNAPSHOT</version>
+      <version>0.38.4-SNAPSHOT</version>
     </dependency>
     <dependency>
       <groupId>tech.tablesaw</groupId>
       <artifactId>tablesaw-docs</artifactId> <!-- An ugly workaround to make sure the docs project isn't built last so that skipNexusStagingDeployMojo works -->
-      <version>0.38.3-SNAPSHOT</version>
+      <version>0.38.4-SNAPSHOT</version>
       <scope>provided</scope>
     </dependency>
     <dependency>
diff --git a/core/pom.xml b/core/pom.xml
index 471bf9f0..30774ccd 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -5,7 +5,7 @@
     <parent>
         <groupId>tech.tablesaw</groupId>
         <artifactId>tablesaw-parent</artifactId>
-        <version>0.38.3-SNAPSHOT</version>
+        <version>0.38.4-SNAPSHOT</version>
     </parent>
 
     <artifactId>tablesaw-core</artifactId>
diff --git a/core/src/main/java/tech/tablesaw/api/Table.java b/core/src/main/java/tech/tablesaw/api/Table.java
index 21ed5766..17c43f8d 100644
--- a/core/src/main/java/tech/tablesaw/api/Table.java
+++ b/core/src/main/java/tech/tablesaw/api/Table.java
@@ -758,10 +758,7 @@ public class Table extends Relation implements Iterable<Row> {
   }
 
   public Table where(Function<Table, Selection> selection) {
-    Table tempTable = where(selection.apply(this));
-    Table newTable = tempTable.emptyCopy(tempTable.rowCount());
-    Rows.copyRowsToTable(selection.apply(this), this, newTable);
-    return newTable;
+    return where(selection.apply(this));
   }
 
   public Table dropWhere(Function<Table, Selection> selection) {
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 65523ce5..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);
   }
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 34e1f34e..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();
       }
     }
   }
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/docs-src/pom.xml b/docs-src/pom.xml
index a6c4cd9a..bd4a94e4 100644
--- a/docs-src/pom.xml
+++ b/docs-src/pom.xml
@@ -5,7 +5,7 @@
     <parent>
         <groupId>tech.tablesaw</groupId>
         <artifactId>tablesaw-parent</artifactId>
-        <version>0.38.3-SNAPSHOT</version>
+        <version>0.38.4-SNAPSHOT</version>
     </parent>
 
     <artifactId>tablesaw-docs</artifactId>
@@ -87,7 +87,7 @@
         <dependency>
             <groupId>tech.tablesaw</groupId>
             <artifactId>tablesaw-core</artifactId>
-            <version>0.38.3-SNAPSHOT</version>
+            <version>0.38.4-SNAPSHOT</version>
             <scope>compile</scope>
         </dependency>
     </dependencies>
diff --git a/excel/pom.xml b/excel/pom.xml
index 7a418861..b98c1242 100644
--- a/excel/pom.xml
+++ b/excel/pom.xml
@@ -5,7 +5,7 @@
   <parent>
     <groupId>tech.tablesaw</groupId>
     <artifactId>tablesaw-parent</artifactId>
-    <version>0.38.3-SNAPSHOT</version>
+    <version>0.38.4-SNAPSHOT</version>
   </parent>
 
   <artifactId>tablesaw-excel</artifactId>
@@ -82,7 +82,7 @@
     <dependency>
       <groupId>tech.tablesaw</groupId>
       <artifactId>tablesaw-core</artifactId>
-      <version>0.38.3-SNAPSHOT</version>
+      <version>0.38.4-SNAPSHOT</version>
       <scope>provided</scope>
     </dependency>
     <dependency>
diff --git a/html/pom.xml b/html/pom.xml
index f61f4877..6d336ed5 100644
--- a/html/pom.xml
+++ b/html/pom.xml
@@ -5,7 +5,7 @@
   <parent>
     <groupId>tech.tablesaw</groupId>
     <artifactId>tablesaw-parent</artifactId>
-    <version>0.38.3-SNAPSHOT</version>
+    <version>0.38.4-SNAPSHOT</version>
   </parent>
 
   <artifactId>tablesaw-html</artifactId>
@@ -81,7 +81,7 @@
     <dependency>
       <groupId>tech.tablesaw</groupId>
       <artifactId>tablesaw-core</artifactId>
-      <version>0.38.3-SNAPSHOT</version>
+      <version>0.38.4-SNAPSHOT</version>
       <scope>provided</scope>
     </dependency>
     <dependency>
diff --git a/json/pom.xml b/json/pom.xml
index 2f6083a7..b408d7b5 100644
--- a/json/pom.xml
+++ b/json/pom.xml
@@ -5,7 +5,7 @@
   <parent>
     <groupId>tech.tablesaw</groupId>
     <artifactId>tablesaw-parent</artifactId>
-    <version>0.38.3-SNAPSHOT</version>
+    <version>0.38.4-SNAPSHOT</version>
   </parent>
 
   <artifactId>tablesaw-json</artifactId>
@@ -81,7 +81,7 @@
     <dependency>
       <groupId>tech.tablesaw</groupId>
       <artifactId>tablesaw-core</artifactId>
-      <version>0.38.3-SNAPSHOT</version>
+      <version>0.38.4-SNAPSHOT</version>
       <scope>provided</scope>
     </dependency>
     <dependency>
diff --git a/jsplot/pom.xml b/jsplot/pom.xml
index 1783e0cc..a2a7b62b 100644
--- a/jsplot/pom.xml
+++ b/jsplot/pom.xml
@@ -5,7 +5,7 @@
     <parent>
         <groupId>tech.tablesaw</groupId>
         <artifactId>tablesaw-parent</artifactId>
-        <version>0.38.3-SNAPSHOT</version>
+        <version>0.38.4-SNAPSHOT</version>
     </parent>
 
     <artifactId>tablesaw-jsplot</artifactId>
@@ -99,7 +99,7 @@
         <dependency>
             <groupId>tech.tablesaw</groupId>
             <artifactId>tablesaw-core</artifactId>
-            <version>0.38.3-SNAPSHOT</version>
+            <version>0.38.4-SNAPSHOT</version>
         </dependency>
         <dependency>
             <groupId>org.junit.jupiter</groupId>
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/components/Annotation.java b/jsplot/src/main/java/tech/tablesaw/plotly/components/Annotation.java
new file mode 100644
index 00000000..561a3cc1
--- /dev/null
+++ b/jsplot/src/main/java/tech/tablesaw/plotly/components/Annotation.java
@@ -0,0 +1,582 @@
+package tech.tablesaw.plotly.components;
+
+import com.fasterxml.jackson.annotation.JsonValue;
+import com.google.common.base.Preconditions;
+import java.util.HashMap;
+import java.util.Map;
+
+public class Annotation extends Component {
+
+  /**
+   * This is to supply annotation in Plot.ly reference to
+   * https://plotly.com/javascript/reference/layout/annotations/. Support most of
+   * apis there.
+   */
+
+  /**
+   * The horizontal alignment of the 'text' within the box.
+   */
+  public enum Align {
+    LEFT("left"), CENTER("center"), RIGHT("right");
+
+    private final String value;
+
+    Align(String value) {
+      this.value = value;
+    }
+
+    @JsonValue
+    @Override
+    public String toString() {
+      return value;
+    }
+  }
+
+  /*
+   * The vertical alignment of the 'text' within the box.
+   */
+  public enum Valign {
+    TOP("top"), MIDDLE("middle"), BOTTOM("bottom");
+
+    private final String value;
+
+    Valign(String value) {
+      this.value = value;
+    }
+
+    @JsonValue
+    @Override
+    public String toString() {
+      return value;
+    }
+  }
+
+  public enum Xanchor {
+    AUTO("auto"), LEFT("left"), CENTER("center"), RIGHT("right");
+
+    private final String value;
+
+    Xanchor(String value) {
+      this.value = value;
+    }
+
+    @JsonValue
+    @Override
+    public String toString() {
+      return value;
+    }
+  }
+
+  public enum Yanchor {
+    AUTO("auto"), TOP("top"), MIDDLE("center"), BOTTOM("right");
+
+    private final String value;
+
+    Yanchor(String value) {
+      this.value = value;
+    }
+
+    @JsonValue
+    @Override
+    public String toString() {
+      return value;
+    }
+  }
+
+  public enum ClicktoShow {
+    FALSE(), ONOFF("onoff"), ONOUT("onout");
+
+    private final String value;
+
+    ClicktoShow(String value) {
+      this.value = value;
+    }
+
+    ClicktoShow() {
+      this.value = "false";
+    }
+
+    @JsonValue
+    @Override
+    public String toString() {
+      return value;
+    }
+  }
+
+  private static final boolean DEFAULT_VISIBLE = true;
+  private static final Font DEFAULT_FONT = Font.builder().build();
+  private static final double DEFAULT_OPACITY = 1;
+  private static final Align DEFAULT_ALIGN = Align.CENTER;
+  private static final Valign DEFAULT_VALIGN = Valign.MIDDLE;
+  private static final String DEFAULT_BGCOLOR = "rgba(0,0,0,0)";
+  private static final String DEFAULT_BORDERCOLOR = "rgba(0,0,0,0)";
+  private static final double DEFAULT_BORDERPAD = 1;
+  private static final double DEFAULT_BORDERWIDTH = 1;
+  private static final boolean DEFAULT_SHOWARROW = true;
+  private static final int DEFAULT_ARROWHEAD = 1;
+  private static final int DEFAULT_STARTARROWHEAD = 1;
+  private static final String DEFAULT_ARROWSIDE = "END";
+  private static final double DEFAULT_ARROWSIZE = 1;
+  private static final double DEFAULT_STARTARROWSIZE = 1;
+  private static final double DEFAULT_ARROWWIDTH = 1;
+  private static final double DEFAULT_STANDOFF = 0;
+  private static final double DEFAULT_STARTSTANDOFF = 0;
+  private static final String DEFAULT_AXREF = "paper";
+  private static final String DEFAULT_AYREF = "paper";
+  private static final String DEFAULT_XREF = "paper";
+  private static final Xanchor DEFAULT_XANCHOR = Xanchor.AUTO;
+  private static final double DEFAULT_XSHIFT = 0;
+  private static final String DEFAULT_YREF = "paper";
+  private static final Yanchor DEFAULT_YANCHOR = Yanchor.AUTO;
+  private static final double DEFAULT_YSHIFT = 0;
+  private static final ClicktoShow DEFAULT_CLICKTOSHOW = ClicktoShow.FALSE;
+  private static final boolean DEFAULT_CAPTUREEVENTS = true;
+
+  private final boolean visible;
+  private final String text;
+  private final Font font;
+  private final Double width;
+  private final Double height;
+  private final double opacity;
+  private final Align align;
+  private final Valign valign;
+  private final String bgcolor;
+  private final String bordercolor;
+  private final double borderpad;
+  private final double borderwidth;
+  private final boolean showarrow;
+  private final String arrowcolor;
+  private final int arrowhead;
+  private final int startarrowhead;
+  private final String arrowside;
+  private final double arrowsize;
+  private final double startarrowsize;
+  private final double arrowwidth;
+  private final double standoff;
+  private final double startstandoff;
+  private final Double ax;
+  private final Double ay;
+  private final String axref;
+  private final String ayref;
+  private final String xref;
+  private final Double x;
+  private final Xanchor xanchor;
+  private final double xshift;
+  private final String yref;
+  private final Double y;
+  private final Yanchor yanchor;
+  private final double yshift;
+  private final ClicktoShow clicktoshow;
+  private final Double xclick;
+  private final Double yclick;
+  private final String hovertext;
+  private final HoverLabel hoverlabel;
+  private final boolean captureevents;
+  private final String name;
+  private final String templateitemname;
+
+  private Annotation(AnnotationBuilder builder) {
+    this.visible = builder.visible;
+    this.text = builder.text;
+    this.font = builder.font;
+    this.width = builder.width;
+    this.height = builder.height;
+    this.opacity = builder.opacity;
+    this.align = builder.align;
+    this.valign = builder.valign;
+    this.bgcolor = builder.bgcolor;
+    this.bordercolor = builder.bordercolor;
+    this.borderpad = builder.borderpad;
+    this.borderwidth = builder.borderwidth;
+    this.showarrow = builder.showarrow;
+    this.arrowcolor = builder.arrowcolor;
+    this.arrowhead = builder.arrowhead;
+    this.startarrowhead = builder.startarrowhead;
+    this.arrowside = builder.arrowside;
+    this.arrowsize = builder.arrowsize;
+    this.startarrowsize = builder.startarrowsize;
+    this.arrowwidth = builder.arrowwidth;
+    this.standoff = builder.standoff;
+    this.startstandoff = builder.startstandoff;
+    this.ax = builder.ax;
+    this.ay = builder.ay;
+    this.axref = builder.axref;
+    this.ayref = builder.ayref;
+    this.xref = builder.xref;
+    this.x = builder.x;
+    this.xanchor = builder.xanchor;
+    this.xshift = builder.xshift;
+    this.yref = builder.yref;
+    this.y = builder.y;
+    this.yanchor = builder.yanchor;
+    this.yshift = builder.yshift;
+    this.clicktoshow = builder.clicktoshow;
+    this.xclick = builder.xclick;
+    this.yclick = builder.yclick;
+    this.hovertext = builder.hovertext;
+    this.hoverlabel = builder.hoverLabel;
+    this.captureevents = builder.captureevents;
+    this.name = builder.name;
+    this.templateitemname = builder.templateitemname;
+  }
+
+  @Override
+  public String asJavascript() {
+    return asJavascript("annotation_template.html");
+  }
+
+  @Override
+  protected Map<String, Object> getContext() {
+    Map<String, Object> context = new HashMap<>();
+    if (DEFAULT_VISIBLE != visible)
+      context.put("visible", visible);
+    if (text != null)
+      context.put("text", text);
+    if (!DEFAULT_FONT.equals(font))
+      context.put("font", font);
+    if (width != null)
+      context.put("width", width);
+    if (height != null)
+      context.put("height", height);
+    if (opacity != DEFAULT_OPACITY)
+      context.put("opacity", opacity);
+    if (!DEFAULT_ALIGN.equals(align))
+      context.put("align", align);
+    if (!DEFAULT_VALIGN.equals(valign))
+      context.put("valign", valign);
+    if (!DEFAULT_BGCOLOR.equals(bgcolor))
+      context.put("bgcolor", bgcolor);
+    if (borderpad != DEFAULT_BORDERPAD)
+      context.put("borderpad", borderpad);
+    if (borderwidth != borderwidth)
+      context.put("borderwidth", bordercolor);
+    if (showarrow != DEFAULT_SHOWARROW)
+      context.put("showarrow", showarrow);
+    if (arrowcolor != null)
+      context.put("arrowcolor", arrowcolor);
+    if (arrowhead != DEFAULT_ARROWHEAD)
+      context.put("arrowhead", arrowhead);
+    if (startarrowhead != DEFAULT_STARTARROWHEAD)
+      context.put("startarrowhead", startarrowhead);
+    if (arrowwidth != DEFAULT_ARROWWIDTH)
+      context.put("arrowwidth", arrowwidth);
+    if (standoff != DEFAULT_STANDOFF)
+      context.put("standoff", standoff);
+    if (ax != null)
+      context.put("ax", ax);
+    if (ay != null)
+      context.put("ay", ay);
+    if (!DEFAULT_AXREF.equals(axref))
+      context.put("axref", axref);
+    if (!DEFAULT_AYREF.equals(ayref))
+      context.put("ayref", ayref);
+    if (!DEFAULT_XREF.equals(xref))
+      context.put("xref", xref);
+    if (x != null)
+      context.put("x", x);
+    if (!DEFAULT_XANCHOR.equals(xanchor))
+      context.put("xanchor", xanchor);
+    if (xshift != DEFAULT_XSHIFT)
+      context.put("xshift", xshift);
+    if (!DEFAULT_YREF.equals(yref))
+      context.put("yref", yref);
+    if (y != null)
+      context.put("y", y);
+    if (!DEFAULT_YANCHOR.equals(yanchor))
+      context.put("yanchor", yanchor);
+    if (yshift != DEFAULT_YSHIFT)
+      context.put("yshift", yshift);
+    if (!DEFAULT_CLICKTOSHOW.equals(clicktoshow))
+      context.put("clicktoshow", clicktoshow);
+    if (xclick != null)
+      context.put("xclick", xclick);
+    if (yclick != null)
+      context.put("yclick", yclick);
+    if (hoverlabel != null)
+      context.put("hoverlabel", hoverlabel);
+    if (captureevents != DEFAULT_CAPTUREEVENTS)
+      context.put("captureevents", captureevents);
+    if (name != null)
+      context.put("name", name);
+    if (templateitemname != null)
+      context.put("templateitemname", templateitemname);
+
+    return context;
+  }
+
+  public static AnnotationBuilder builder() {
+    return new AnnotationBuilder();
+  }
+
+  public static class AnnotationBuilder {
+    private boolean visible = DEFAULT_VISIBLE;
+    private String text;
+    private Font font = DEFAULT_FONT;
+    private Double width;
+    private Double height;
+    private double opacity = DEFAULT_OPACITY;
+    private Align align = DEFAULT_ALIGN;
+    private Valign valign = DEFAULT_VALIGN;
+    private String bgcolor = DEFAULT_BGCOLOR;
+    private String bordercolor = DEFAULT_BORDERCOLOR;
+    private double borderpad = DEFAULT_BORDERPAD;
+    private double borderwidth = DEFAULT_BORDERWIDTH;
+    private boolean showarrow = DEFAULT_SHOWARROW;
+    private String arrowcolor;
+    private int arrowhead = DEFAULT_ARROWHEAD;
+    private int startarrowhead = DEFAULT_STARTARROWHEAD;
+    private String arrowside = DEFAULT_ARROWSIDE;
+    private double arrowsize = DEFAULT_ARROWSIZE;
+    private double startarrowsize = DEFAULT_STARTARROWSIZE;
+    private double arrowwidth = DEFAULT_ARROWWIDTH;
+    private double standoff = DEFAULT_STANDOFF;
+    private double startstandoff = DEFAULT_STARTSTANDOFF;
+    private Double ax;
+    private Double ay;
+    private String axref = DEFAULT_AXREF;
+    private String ayref = DEFAULT_AYREF;
+    private String xref = DEFAULT_XREF;
+    private Double x;
+    private Xanchor xanchor = DEFAULT_XANCHOR;
+    private double xshift = DEFAULT_XSHIFT;
+    private String yref = DEFAULT_YREF;
+    private Double y;
+    private Yanchor yanchor = DEFAULT_YANCHOR;
+    private double yshift = DEFAULT_YSHIFT;
+    private ClicktoShow clicktoshow;
+    private Double xclick;
+    private Double yclick;
+    private String hovertext;
+    private HoverLabel hoverLabel;
+    private boolean captureevents = DEFAULT_CAPTUREEVENTS;
+    private String name;
+    private String templateitemname;
+
+    private AnnotationBuilder() {
+    }
+
+    public AnnotationBuilder visible(boolean visible) {
+      this.visible = visible;
+      return this;
+    }
+
+    public AnnotationBuilder text(String text) {
+      this.text = text;
+      return this;
+    }
+
+    public AnnotationBuilder font(Font font) {
+      this.font = font;
+      return this;
+    }
+
+    public AnnotationBuilder width(double width) {
+      Preconditions.checkArgument(width >= 0);
+      this.width = width;
+      return this;
+    }
+
+    public AnnotationBuilder height(double height) {
+      Preconditions.checkArgument(height >= 0);
+      this.height = height;
+      return this;
+    }
+
+    public AnnotationBuilder opacity(double opacity) {
+      Preconditions.checkArgument(opacity >= 0 && opacity <= 1);
+      this.opacity = opacity;
+      return this;
+    }
+
+    public AnnotationBuilder align(Align align) {
+      this.align = align;
+      return this;
+    }
+
+    public AnnotationBuilder Valign(Valign valign) {
+      this.valign = valign;
+      return this;
+    }
+
+    public AnnotationBuilder bgcolor(String bgcolor) {
+      this.bgcolor = bgcolor;
+      return this;
+    }
+
+    public AnnotationBuilder bordercolor(String bordercolor) {
+      this.bordercolor = bordercolor;
+      return this;
+    }
+
+    public AnnotationBuilder borderpad(double borderpad) {
+      Preconditions.checkArgument(borderpad >= 0);
+      this.borderpad = borderpad;
+      return this;
+    }
+
+    public AnnotationBuilder borderwidth(double borderwidth) {
+      Preconditions.checkArgument(borderwidth >= 0);
+      this.borderwidth = borderwidth;
+      return this;
+    }
+
+    public AnnotationBuilder showarrow(boolean showarrow) {
+      this.showarrow = showarrow;
+      return this;
+    }
+
+    public AnnotationBuilder arrowcolor(String arrowcolor) {
+      this.arrowcolor = arrowcolor;
+      return this;
+    }
+
+    public AnnotationBuilder arrowhead(int arrowhead) {
+      this.arrowhead = arrowhead;
+      return this;
+    }
+
+    public AnnotationBuilder startarrowhead(int startarrowhead) {
+      this.startarrowhead = startarrowhead;
+      return this;
+    }
+
+    public AnnotationBuilder arrowside(String arrowside) {
+      this.arrowside = arrowside;
+      return this;
+    }
+
+    public AnnotationBuilder arrowsize(double arrowsize) {
+      Preconditions.checkArgument(arrowsize >= 0.3);
+      this.arrowsize = arrowsize;
+      return this;
+    }
+
+    public AnnotationBuilder startarrowsize(double startarrowsize) {
+      Preconditions.checkArgument(startarrowsize >= 0.3);
+      this.startarrowsize = startarrowsize;
+      return this;
+    }
+
+    public AnnotationBuilder arrowwidth(double arrowwidth) {
+      Preconditions.checkArgument(arrowwidth >= 0.1);
+      this.arrowwidth = arrowwidth;
+      return this;
+    }
+
+    public AnnotationBuilder standoff(double standoff) {
+      Preconditions.checkArgument(standoff >= 0);
+      this.standoff = standoff;
+      return this;
+    }
+
+    public AnnotationBuilder startstandoff(double startstandoff) {
+      Preconditions.checkArgument(startstandoff >= 0);
+      this.startstandoff = startstandoff;
+      return this;
+    }
+
+    public AnnotationBuilder ax(double ax) {
+      this.ax = ax;
+      return this;
+    }
+
+    public AnnotationBuilder ay(double ay) {
+      this.ay = ay;
+      return this;
+    }
+
+    public AnnotationBuilder axref(String axref) {
+      this.axref = axref;
+      return this;
+    }
+
+    public AnnotationBuilder ayref(String ayref) {
+      this.ayref = ayref;
+      return this;
+    }
+
+    public AnnotationBuilder xref(String xref) {
+      this.xref = xref;
+      return this;
+    }
+
+    public AnnotationBuilder x(double x) {
+      this.x = x;
+      return this;
+    }
+
+    public AnnotationBuilder xanchor(Xanchor xanchor) {
+      this.xanchor = xanchor;
+      return this;
+    }
+
+    public AnnotationBuilder xshift(double xshift) {
+      this.xshift = xshift;
+      return this;
+    }
+
+    public AnnotationBuilder yref(String yref) {
+      this.yref = yref;
+      return this;
+    }
+
+    public AnnotationBuilder y(double y) {
+      this.y = y;
+      return this;
+    }
+
+    public AnnotationBuilder yanchor(Yanchor yanchor) {
+      this.yanchor = yanchor;
+      return this;
+    }
+
+    public AnnotationBuilder yshift(double yshift) {
+      this.yshift = yshift;
+      return this;
+    }
+
+    public AnnotationBuilder clicktoshow(ClicktoShow clicktoShow) {
+      this.clicktoshow = clicktoShow;
+      return this;
+    }
+
+    public AnnotationBuilder xclick(double xclick) {
+      this.xclick = xclick;
+      return this;
+    }
+
+    public AnnotationBuilder yclick(double yclick) {
+      this.yclick = yclick;
+      return this;
+    }
+
+    public AnnotationBuilder hovertext(String hovertext) {
+      this.hovertext = hovertext;
+      return this;
+    }
+
+    public AnnotationBuilder hoverlabel(HoverLabel hoverLabel) {
+      this.hoverLabel = hoverLabel;
+      return this;
+    }
+
+    public AnnotationBuilder captureevents(boolean captureevents) {
+      this.captureevents = captureevents;
+      return this;
+    }
+
+    public AnnotationBuilder name(String name) {
+      this.name = name;
+      return this;
+    }
+
+    public AnnotationBuilder templateitemname(String templateitemname) {
+      this.templateitemname = templateitemname;
+      return this;
+    }
+
+    public Annotation build() {
+      return new Annotation(this);
+    }
+  }
+}
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/annotation_template.html b/jsplot/src/main/resources/annotation_template.html
new file mode 100644
index 00000000..580bc07e
--- /dev/null
+++ b/jsplot/src/main/resources/annotation_template.html
@@ -0,0 +1,113 @@
+{
+{% if visible is not null %}
+visible: {{visible}},
+{% endif %}
+{% if text is not null %}
+text: "{{text}}",
+{% endif %}
+{% if font is not null %}
+font: {{font.asJavascript() | raw}},
+{% endif %}
+{% if width is not null %}
+width: {{width}},
+{% endif %}
+{% if height is not null %}
+height: {{height}},
+{% endif %}
+{% if opacity is not null %}
+opacity: {{opacity}},
+{% endif %}
+{% if align is not null %}
+align: "{{align}}",
+{% endif %}
+{% if valign is not null %}
+valign: "{{valign}}",
+{% endif %}
+{% if bgcolor is not null %}
+bgcolor: "{{bgcolor}}",
+{% endif %}
+{% if bordercolor is not null %}
+bordercolor: "{{bordercolor}}",
+{% endif %}
+{% if borderwidth is not null %}
+borderwidth: {{borderwidth}},
+{% endif %}
+{% if showarrow is not null %}
+showarrow: {{showarrow}},
+{% endif %}
+{% if arrowcolor is not null %}
+arrowcolor: "{{arrowcolor}}",
+{% endif %}
+{% if arrowhead is not null %}
+arrowhead: {{arrowhead}},
+{% endif %}
+{% if startarrowhead is not null %}
+startarrowhead: {{startarrowhead}},
+{% endif %}
+{% if arrowwidth is not null %}
+arrowwidth: {{arrowwidth}},
+{% endif %}
+{% if standoff is not null %}
+standoff: {{standoff}},
+{% endif %}
+{% if startstandoff is not null %}
+startstandoff: {{startstandoff}},
+{% endif %}
+{% if ax is not null %}
+ax: {{ax}},
+{% endif %}
+{% if ay is not null %}
+ay: {{ay}},
+{% endif %}
+{% if axref is not null %}
+axref: "{{axref}}",
+{% endif %}
+{% if ayref is not null %}
+ayref: "{{ayref}}",
+{% endif %}
+{% if xref is not null %}
+xref: "{{xref}}",
+{% endif %}
+{% if x is not null %}
+x: {{x}},
+{% endif %}
+{% if xanchor is not null %}
+xanchor: "{{xanchor}}",
+{% endif %}
+{% if xshift is not null %}
+xshift: "{{xshift}}",
+{% endif %}
+{% if yref is not null %}
+yref: "{{yref}}",
+{% endif %}
+{% if y is not null %}
+y: {{y}},
+{% endif %}
+{% if yanchor is not null %}
+yanchor: "{{yanchor}}",
+{% endif %}
+{% if yshift is not null %}
+yshift: {{yshift}},
+{% endif %}
+{% if clicktoshow is not null %}
+clicktoshow: "{{clicktoshow}}",
+{% endif %}
+{% if xclick is not null %}
+xclick: {{xclick}},
+{% endif %}
+{% if yclick is not null %}
+yclick: {{yclick}},
+{% endif %}
+{% if hovertext is not null %}
+hovertext: "{{hovertext}}",
+{% endif %}
+{% if hoverlabel is not null %}
+hoverlabel: {{hoverlabel.asJavascript() | raw}},
+{% endif %}
+{% if name is not null %}
+name: "{{name}}",
+{% endif %}
+{% if templateitemname is not null %}
+templateitemname: "{{templateitemname}}"
+{% endif %}
+}
\ No newline at end of file
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
diff --git a/pom.xml b/pom.xml
index 506d541d..66e16e96 100644
--- a/pom.xml
+++ b/pom.xml
@@ -4,7 +4,7 @@
 
     <groupId>tech.tablesaw</groupId>
     <artifactId>tablesaw-parent</artifactId>
-    <version>0.38.3-SNAPSHOT</version>
+    <version>0.38.4-SNAPSHOT</version>
     <name>Tablesaw</name>
     <description>High-performance Java Dataframe</description>
     <url>https://github.com/jtablesaw/tablesaw</url>
diff --git a/saw/pom.xml b/saw/pom.xml
index 3a2db653..4c310f58 100644
--- a/saw/pom.xml
+++ b/saw/pom.xml
@@ -5,7 +5,7 @@
   <parent>
     <groupId>tech.tablesaw</groupId>
     <artifactId>tablesaw-parent</artifactId>
-    <version>0.38.3-SNAPSHOT</version>
+    <version>0.38.4-SNAPSHOT</version>
   </parent>
 
   <artifactId>tablesaw-saw</artifactId>
@@ -81,7 +81,7 @@
     <dependency>
       <groupId>tech.tablesaw</groupId>
       <artifactId>tablesaw-core</artifactId>
-      <version>0.38.3-SNAPSHOT</version>
+      <version>0.38.4-SNAPSHOT</version>
       <scope>provided</scope>
     </dependency>
     <dependency>


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
index f2d30336..3f3a2c8d 100644
--- a/core/src/test/java/tech/tablesaw/io/fixed/FixedWidthWriterTest.java
+++ b/core/src/test/java/tech/tablesaw/io/fixed/FixedWidthWriterTest.java
@@ -5,6 +5,9 @@ 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;
@@ -43,4 +46,140 @@ public class FixedWidthWriterTest {
             + "",
         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/components/AnnotationTest.java b/jsplot/src/test/java/tech/tablesaw/components/AnnotationTest.java
new file mode 100644
index 00000000..2577a446
--- /dev/null
+++ b/jsplot/src/test/java/tech/tablesaw/components/AnnotationTest.java
@@ -0,0 +1,39 @@
+package tech.tablesaw.components;
+
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import tech.tablesaw.plotly.components.Annotation;
+import tech.tablesaw.plotly.components.Font;
+
+@Disabled
+public class AnnotationTest {
+  @Test
+  public void asJavascript() {
+    Annotation annotation = Annotation.builder().xref("paper").
+        yref("paper")
+        .x(0)
+        .y(0)
+        .yanchor(Annotation.Yanchor.BOTTOM).
+        bordercolor("#c7c7c7")
+        .font(Font.builder().build())
+        .text("X asis label")
+        .showarrow(false).build();
+    System.out.println(annotation.asJavascript());
+  }
+
+  @Test
+  public void asJavascript2() {
+    Annotation annotation = Annotation.builder()
+        .xref("x")
+        .yref("y")
+        .x(0)
+        .y(0)
+        .yanchor(Annotation.Yanchor.BOTTOM)
+        .bordercolor("#c7c7c7")
+        .font(Font.builder().family(Font.Family.SANS_SERIF).size(16).color("#ffffff").build())
+        .text("max=5").align(Annotation.Align.CENTER).arrowhead(2).arrowsize(1).arrowwidth(2).ax(20).ay(-30)
+        .font(Font.builder().color("rgba(0,0,0,0)").build())
+        .showarrow(true).build();
+    System.out.println(annotation.asJavascript());
+  }
+}
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);
+    }
+}