diff --git a/.github/workflows/scala.yml b/.github/workflows/scala.yml index 61b290720b1a..9287751bdee0 100644 --- a/.github/workflows/scala.yml +++ b/.github/workflows/scala.yml @@ -236,6 +236,7 @@ jobs: $ENGINE_DIST_DIR/bin/enso --run test/Table_Tests $ENGINE_DIST_DIR/bin/enso --run test/Database_Tests $ENGINE_DIST_DIR/bin/enso --run test/Geo_Tests + $ENGINE_DIST_DIR/bin/enso --run test/Visualization_Tests - name: Test Engine Distribution (Windows) shell: bash @@ -245,6 +246,7 @@ jobs: $ENGINE_DIST_DIR/bin/enso.bat --run test/Table_Tests $ENGINE_DIST_DIR/bin/enso.bat --run test/Database_Tests $ENGINE_DIST_DIR/bin/enso.bat --run test/Geo_Tests + $ENGINE_DIST_DIR/bin/enso.bat --run test/Visualization_Tests # Publish - name: Publish the Engine Distribution Artifact diff --git a/distribution/std-lib/Standard/src/Visualization/Helpers.enso b/distribution/std-lib/Standard/src/Visualization/Helpers.enso index 612572b6d58a..db6fb16bdabd 100644 --- a/distribution/std-lib/Standard/src/Visualization/Helpers.enso +++ b/distribution/std-lib/Standard/src/Visualization/Helpers.enso @@ -1,8 +1,40 @@ from Standard.Base import all +import Standard.Table.Data.Column +import Standard.Table.Data.Storage +import Standard.Table.Data.Table + +## PRIVATE +Any.catch_: Any -> Any +Any.catch_ ~val = this.catch (_-> val) + ## PRIVATE +Any.catch_ : Any -> Any +Error.catch_ ~val = this.catch (_-> val) + +## PRIVATE +recover_errors : Any -> Any recover_errors ~body = result = Panic.recover body result.catch err-> Json.from_pairs [["error", err.to_display_text]] . to_text +## PRIVATE + + Returns all the columns in the table, including indices. + Index columns are placed before other columns. +Table.Table.all_columns : Vector +Table.Table.all_columns = + index = this.index.catch_ [] + index_columns = case index of + Vector.Vector _ -> index + a -> [a] + index_columns + this.columns + + +## PRIVATE + + Checks if the column stores numbers. +Column.Column.is_numeric : Boolean +Column.Column.is_numeric = + [Storage.Integer,Storage.Decimal].contains this.storage_type diff --git a/distribution/std-lib/Standard/src/Visualization/Histogram.enso b/distribution/std-lib/Standard/src/Visualization/Histogram.enso new file mode 100644 index 000000000000..dc71d8bebf24 --- /dev/null +++ b/distribution/std-lib/Standard/src/Visualization/Histogram.enso @@ -0,0 +1,69 @@ +from Standard.Base import all + +import Standard.Table.Data.Column +import Standard.Table.Data.Table +import Standard.Visualization.Helpers + +## PRIVATE + Get first numeric column of the table. +Table.Table.first_numeric : Table -> Column ! Nothing +Table.Table.first_numeric = this.all_columns.find _.is_numeric + +## PRIVATE + Get the value column - the column that will be used to create histogram. +Table.Table.value_column : Table -> Column ! Nothing +Table.Table.value_column = + named_col = this.at 'value' + named_col.catch_ <| this.first_numeric + +## PRIVATE + Information that are placed in an update sent to a visualization. +type Update + ## PRIVATE + type Update values label + + ## PRIVATE + Generate JSON that can be consumed by the visualization. + to_json : Object + to_json = + data = ['data', Json.from_pairs [['values', this.values]]] + axis = ['axis', Json.from_pairs [['x', Json.from_pairs [['label', this.label]]]]] + ret_pairs = case this.label of + Nothing -> [data] + _ -> [axis,data] + Json.from_pairs ret_pairs + +## PRIVATE +from_table : Table -> Update +from_table table = + col = table.value_column + label = col.name.catch_ Nothing + values = col.to_vector.catch_ [] + Update values label + +## PRIVATE +from_vector : Vector -> Update +from_vector vector = + Update vector Nothing + +## PRIVATE +from_value : Any -> Update +from_value value = + case value of + Table.Table _ -> here.from_table value + Vector.Vector _ -> here.from_vector value + Column.Column _ -> here.from_table value.to_table + _ -> here.from_vector value.to_vector + +## PRIVATE + + Default preprocessor for the histogram visualization. + + Generates JSON text describing the histogram visualization. + + Arguments: + - value: the value to be visualized. +process_to_json_text : Any -> Text +process_to_json_text value = + update = here.from_value value + update.to_json.to_text diff --git a/distribution/std-lib/Standard/src/Visualization/Scatter_Plot.enso b/distribution/std-lib/Standard/src/Visualization/Scatter_Plot.enso new file mode 100644 index 000000000000..0aa4b1acd185 --- /dev/null +++ b/distribution/std-lib/Standard/src/Visualization/Scatter_Plot.enso @@ -0,0 +1,151 @@ +from Standard.Base import all + +import Standard.Table.Data.Column +import Standard.Table.Data.Table +import Standard.Visualization.Helpers + +## PRIVATE + Name of the index column that may be generated to plot against. +index_name : Text +index_name = 'index' + +## PRIVATE +data_field : Text +data_field = 'data' + +## PRIVATE +axis_field : Text +axis_field = 'axis' + +## PRIVATE +label_field : Text +label_field = 'label' + +## PRIVATE + Represents a recognized point data field for a scatter plot visualization. +type PointData + ## PRIVATE + type PointData + ## PRIVATE + type X + ## PRIVATE + type Y + ## PRIVATE + type Color + ## PRIVATE + type Shape + ## PRIVATE + type Label + ## PRIVATE + type Size + + ## PRIVATE + Returns all recognized point data fields. + all_fields : Vector + all_fields = [X,Y,Color,Shape,Label,Size] + + ## PRIVATE + recognized_names : Vector + recognized_names = this.all_fields.map _.name + + ## PRIVATE + is_recognized : Column -> Boolean + is_recognized column = this.recognized_names.contains column.name + + ## PRIVATE + name : Text + name = this.to_text.to_lower_case + + ## PRIVATE + fallback_column : Table -> Column ! Nothing + fallback_column table = case this of + X -> table.index.catch_ <| this.iota table.row_count + Y -> + x_column = X.lookup_in table + candidates = table.all_columns + is_good_enough c = c.is_numeric && c.name != x_column.name + is_good c = is_good_enough c && (this.is_recognized c).not + + candidates.find is_good . catch_ <| candidates.find is_good_enough + _ -> Error.throw Nothing + + ## PRIVATE + Returns a vector of subsequent integers beginning from 0. + iota : Number -> Vector + iota count = + # FIXME [mwu]: Adjust once https://github.com/enso-org/enso/issues/1439 + # is addressed. + range = 0.up_to <| count + 1 + Column.from_vector here.index_name range.to_vector + + ## PRIVATE + lookup_in : Table -> Column + lookup_in table = + named = table.at this.name + named.catch_ <| this.fallback_column table + +## PRIVATE + Generates JSON that describes points data. +Table.Table.point_data : Table -> Object +Table.Table.point_data = + get_point_data field = field.lookup_in this . rename field.name + is_valid column = column.is_error.not + columns = PointData.all_fields.map get_point_data . filter is_valid + (0.up_to <| this.row_count + 1).to_vector.map <| row_n-> + pairs = columns.map column-> + value = column.at row_n . catch_ Nothing + [column.name, value] + Json.from_pairs pairs + +## PRIVATE + Generates JSON that describes plot axes. +Table.Table.axes : Table -> Object +Table.Table.axes = + describe_axis field = + col_name = field.lookup_in this . name + label = Json.from_pairs [[here.label_field, col_name]] + [field.name, label] + x_axis = describe_axis X + y_axis = describe_axis Y + is_valid axis_pair = + label = axis_pair.at 1 + label.is_error.not && (this.all_columns.length > 0) + axes_obj = Json.from_pairs <| [x_axis, y_axis].filter is_valid + if axes_obj.fields.size > 0 then axes_obj else Nothing + +## PRIVATE +Vector.Vector.point_data : Vector -> Object +Vector.Vector.point_data = + this.map_with_index <| i-> elem-> + Json.from_pairs [[X.name,i],[Y.name,elem]] + +## PRIVATE + +json_from_table table = + data = table.point_data + axes = table.axes + Json.from_pairs <| [[here.data_field,data], [here.axis_field, axes]] + +## PRIVATE +json_from_vector vec = + data = [here.data_field, vec.point_data] + axes = [here.axis_field, Nothing] + Json.from_pairs [data, axes] + +## PRIVATE + + Default preprocessor for the scatterplot visualization. + + Generates JSON text describing the scatterplot visualization. + + Arguments: + - value: the value to be visualized. +process_to_json_text : Any -> Text +process_to_json_text value = + json = case value of + Column.Column _ -> here.json_from_table value.to_table + Table.Table _ -> here.json_from_table value + Vector.Vector _ -> here.json_from_vector value + _ -> here.json_from_vector value.to_vector + + json.to_text diff --git a/test/Visualization_Tests/package.yaml b/test/Visualization_Tests/package.yaml new file mode 100644 index 000000000000..4a7b54cdf7be --- /dev/null +++ b/test/Visualization_Tests/package.yaml @@ -0,0 +1,6 @@ +name: Visualization_Tests +version: 0.0.1 +enso-version: default +license: MIT +author: enso-dev@enso.org +maintainer: enso-dev@enso.org diff --git a/test/Visualization_Tests/src/Helpers_Spec.enso b/test/Visualization_Tests/src/Helpers_Spec.enso new file mode 100644 index 000000000000..ea3a625ba990 --- /dev/null +++ b/test/Visualization_Tests/src/Helpers_Spec.enso @@ -0,0 +1,34 @@ +from Standard.Base import all + +import Standard.Table.Data.Table +import Standard.Test +import Standard.Visualization.Helpers + +import Visualization_Tests + +spec = + Test.group "Table.all_columns" <| + Test.specify "works with empty table" <| + table = Table.from_rows [] [] + table.all_columns.map (_.name) . should_equal [] + + Test.specify "works when there is no index set" <| + header = ['a', 'b'] + row_1 = [11 , 10 ] + row_2 = [21 , 20 ] + table = Table.from_rows header [row_1, row_2] + table.all_columns.map (_.name) . should_equal ['a','b'] + + Test.specify "works when there is nothing but index" <| + header = ['a'] + row_1 = [11 ] + row_2 = [21 ] + table = Table.from_rows header [row_1, row_2] + table.all_columns.map (_.name) . should_equal ['a'] + + Test.specify "includes both normal and index columns" <| + header = ['a', 'b'] + row_1 = [11 , 10 ] + row_2 = [21 , 20 ] + table = Table.from_rows header [row_1, row_2] . set_index 'a' + table.all_columns.map (_.name) . should_equal ['a','b'] diff --git a/test/Visualization_Tests/src/Histogram_Spec.enso b/test/Visualization_Tests/src/Histogram_Spec.enso new file mode 100644 index 000000000000..e1122ff9301e --- /dev/null +++ b/test/Visualization_Tests/src/Histogram_Spec.enso @@ -0,0 +1,60 @@ +from Standard.Base import all + +import Standard.Table.Data.Column +import Standard.Table.Data.Table +import Standard.Test +import Standard.Visualization.Histogram + +import Visualization_Tests + +spec = + expect value expected_label expected_values = + text = Histogram.process_to_json_text value + json = Json.parse text + expected_data = Json.from_pairs [['values', expected_values]] + expected_json = case expected_label of + Nothing -> Json.from_pairs [['data', expected_data]] + _ -> + expected_x = Json.from_pairs [['label', expected_label]] + expected_axis = ['axis', Json.from_pairs [['x', expected_x]]] + Json.from_pairs [['data', expected_data], expected_axis] + json.should_equal expected_json + + Test.group "Histogram Visualization" <| + + Test.specify "deals with an empty table" <| + table = Table.from_rows [] [] + expect table Nothing [] + + Test.specify "plots first column if none recognized" <| + header = ['α', 'ω'] + row_1 = [11 , 10 ] + row_2 = [21 , 20 ] + table = Table.from_rows header [row_1, row_2] + expect table 'α' [11,21] + + Test.specify "plots first column if none recognized even if index" <| + header = ['α'] + row_1 = [11 ] + row_2 = [21 ] + table = Table.from_rows header [row_1, row_2] . set_index 'α' + expect table 'α' [11,21] + + Test.specify "plots 'value' numeric column if present" <| + header = ['α', 'value'] + row_1 = [11 , 10 ] + row_2 = [21 , 20 ] + table = Table.from_rows header [row_1, row_2] + expect table 'value' [10,20] + + Test.specify "plots column" <| + column = Column.from_vector 'my_name' [1,4,6] + expect column 'my_name' [1,4,6] + + Test.specify "plots vector" <| + vector = [1,2,3] + expect vector Nothing vector + + Test.specify "plots range" <| + vector = 2.up_to 4 + expect vector Nothing [2,3,4] diff --git a/test/Visualization_Tests/src/Main.enso b/test/Visualization_Tests/src/Main.enso new file mode 100644 index 000000000000..ad34090dd413 --- /dev/null +++ b/test/Visualization_Tests/src/Main.enso @@ -0,0 +1,12 @@ +from Standard.Base import all + +import Standard.Test + +import Visualization_Tests.Helpers_Spec +import Visualization_Tests.Histogram_Spec +import Visualization_Tests.Scatter_Plot_Spec + +main = Test.Suite.runMain <| + Helpers_Spec.spec + Histogram_Spec.spec + Scatter_Plot_Spec.spec diff --git a/test/Visualization_Tests/src/Scatter_Plot_Spec.enso b/test/Visualization_Tests/src/Scatter_Plot_Spec.enso new file mode 100644 index 000000000000..1c30fe4157a5 --- /dev/null +++ b/test/Visualization_Tests/src/Scatter_Plot_Spec.enso @@ -0,0 +1,100 @@ +from Standard.Base import all + +import Standard.Table.Data.Column +import Standard.Table.Data.Table +import Standard.Test +import Standard.Visualization.Scatter_Plot + +import Visualization_Tests + +spec = + expect value axis_expected_text data_expected_text = + text = Scatter_Plot.process_to_json_text value + json = Json.parse text + json.fields.keys.should_equal ['axis','data'] + + expected_axis_labels = ['axis', Json.parse axis_expected_text] + expected_data_pair = ['data', Json.parse data_expected_text] + expected_result = Json.from_pairs [expected_axis_labels, expected_data_pair] + json.should_equal expected_result + + index = Scatter_Plot.index_name + axis label = Json.from_pairs [['label',label]] + labels x y = Json.from_pairs [['x', axis x], ['y', axis y]] . to_text + no_labels = 'null' + + Test.group "Scatter Plot Visualization" <| + + Test.specify "deals with an empty table" <| + table = Table.from_rows [] [] + expect table 'null' '[]' + + Test.specify "plots first column if none recognized" <| + header = ['α', 'ω'] + row_1 = [11 , 10 ] + row_2 = [21 , 20 ] + table = Table.from_rows header [row_1, row_2] + expect table (labels index 'α') '[{"x":0,"y":11},{"x":1,"y":21}]' + + Test.specify "plots 'y' against indices when no 'x' recognized" <| + header = ['α', 'y'] + row_1 = [11 , 10 ] + row_2 = [21 , 20 ] + table = Table.from_rows header [row_1, row_2] + expect table (labels index 'y') '[{"x":0,"y":10},{"x":1,"y":20}]' + + Test.specify "recognizes all relevant columns" <| + header = ['x' , 'y' , 'size' , 'shape' , 'label' , 'color' ] + row_1 = [11 , 10 , 50 , 'square' , 'label' , 'ff0000'] + table = Table.from_rows header [row_1] + expect table (labels 'x' 'y') '[{"color":"ff0000","label":"label","shape":"square","size":50,"x":11,"y":10}]' + + Test.specify "uses first unrecognized numeric column as `y` fallback" <| + header = ['x' , 'size' , 'name' , 'z' , 'ω'] + row_1 = [11 , 50 , 'circul' , 20 , 30] + table = Table.from_rows header [row_1] + expect table (labels 'x' 'z') '[{"size":50,"x":11,"y":20}]' + + Test.specify "provided only recognized columns" <| + header = ['x', 'y' , 'bar' , 'size'] + row_1 = [11 , 10 , 'aa' , 40 ] + row_2 = [21 , 20 , 'bb' , 50 ] + table = Table.from_rows header [row_1, row_2] + expect table (labels 'x' 'y') '[{"size":40,"x":11,"y":10},{"size":50,"x":21,"y":20}]' + + Test.specify "used specified numeric index for x if missing 'x' column from table" <| + header = [ 'y' , 'foo', 'bar', 'baz' , 'size'] + row_1 = [ 10 , 'aa' , 12 , 14 , 40 ] + row_2 = [ 20 , 'bb' , 13 , 15 , 50 ] + table = Table.from_rows header [row_1, row_2] . set_index 'baz' + # [TODO] mwu: When it is possible to set multiple index columns, test such case. + expect table (labels 'baz' 'y') '[{"size":40,"x":14,"y":10},{"size":50,"x":15,"y":20}]' + + + Test.specify "prefers explicit 'x' to index and looks into indices for recognized fields" <| + header = [ 'x' , 'size'] + row_1 = [ 10 , 21 ] + row_2 = [ 20 , 22 ] + table = Table.from_rows header [row_1, row_2] . set_index 'size' + # FIXME [mwu] Below the `size` field should be present. Depends on + # https://github.com/enso-org/enso/issues/1602 + expect table (labels 'x' 'size') '[{"x":10,"y":21},{"x":20,"y":22}]' + + Test.specify "used default index for `x` if none set" <| + header = [ 'y' , 'bar' , 'size'] + row_1 = [ 10 , 'aa' , 40 ] + row_2 = [ 20 , 'bb' , 50 ] + table = Table.from_rows header [row_1, row_2] + expect table (labels index 'y') '[{"size":40,"x":0,"y":10},{"size":50,"x":1,"y":20}]' + + Test.specify "using indices for x if given a vector" <| + vector = [0,10,20] + expect vector no_labels '[{"x":0,"y":0},{"x":1,"y":10},{"x":2,"y":20}]' + + Test.specify "using indices for x if given a column" <| + column = Column.from_vector 'some_col' [10,2,3] + expect column (labels 'index' 'some_col') '[{"x":0,"y":10},{"x":1,"y":2},{"x":2,"y":3}]' + + Test.specify "using indices for x if given a range" <| + value = 2.up_to 4 + expect value no_labels '[{"x":0,"y":2},{"x":1,"y":3},{"x":2,"y":4}]'