diff --git a/.circleci/config.yml b/.circleci/config.yml index 0997114c4..1ca878cd7 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -43,6 +43,7 @@ defaults: &defaults - run: name: Run JS Tests & Coverage command: | + export TZ=America/New_York yarn run test-with-coverage --maxWorkers=50% bash <(curl -s https://codecov.io/bash) -c -F javascript -f ./JS_coverage/lcov.info yarn run report-duplicate-code diff --git a/dtale/app.py b/dtale/app.py index 03769261a..12022d44d 100644 --- a/dtale/app.py +++ b/dtale/app.py @@ -484,7 +484,7 @@ def _start(): if cli is not None: cli.show_server_banner = lambda *x: None - app.run(host='0.0.0.0', port=ACTIVE_PORT, debug=debug) + app.run(host='0.0.0.0', port=ACTIVE_PORT, debug=debug, threaded=True) if subprocess: if is_active: diff --git a/dtale/static/css/main.css b/dtale/static/css/main.css index 92b5a68a3..afbf7feb1 100644 --- a/dtale/static/css/main.css +++ b/dtale/static/css/main.css @@ -10224,7 +10224,7 @@ table tbody tr.highlight-row { background-color: rgba(82, 187, 239, 0.2); } -.custom-select.tag-select { +.custom-select.axis-select { background: white url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 2'%3E%3Cpath fill='%23333' d='M2 2 L0 0 H4 Z'/%3E%3C/svg%3E") no-repeat right 0.75rem center; background-clip: padding-box; background-size: 8px 10px; diff --git a/dtale/templates/dtale/base.html b/dtale/templates/dtale/base.html index 8347a9de4..7d4adf202 100644 --- a/dtale/templates/dtale/base.html +++ b/dtale/templates/dtale/base.html @@ -23,9 +23,9 @@ display:block; top:0; right:0; - width:200px; + width:125px; overflow:hidden; - height:200px; + height:125px; z-index:9999; } #forkongithub a{ @@ -35,13 +35,13 @@ font-family:arial,sans-serif; text-align:center; font-weight:bold; - font-size:0.75rem; + font-size:0.45rem; line-height:2rem; transition:0.5s; - width:200px; + width:125px; position:absolute; - top:45px; - right:-45px; + top:17px; + right:-31px; transform:rotate(45deg); -webkit-transform:rotate(45deg); -ms-transform:rotate(45deg); diff --git a/dtale/utils.py b/dtale/utils.py index 7b17537e8..51fb5991c 100644 --- a/dtale/utils.py +++ b/dtale/utils.py @@ -323,7 +323,11 @@ def format_dicts(self, lsts): return [self.format_dict(l) for l in lsts] def format_lists(self, df): - return {name: [f(v, nan_display=self.nan_display) for v in df[name].values] for _idx, name, f in self.fmts} + return { + name: [f(v, nan_display=self.nan_display) for v in df[name].values] + for _idx, name, f in self.fmts + if name in df.columns + } def classify_type(type_name): diff --git a/dtale/views.py b/dtale/views.py index 6b7cd34eb..e83b26057 100644 --- a/dtale/views.py +++ b/dtale/views.py @@ -16,12 +16,12 @@ from dtale.cli.clickutils import retrieve_meta_info_and_version from dtale.utils import (build_shutdown_url, classify_type, dict_merge, filter_df_for_grid, find_dtype_formatter, - find_selected_column, get_dtypes, get_int_arg, - get_str_arg, grid_columns, grid_formatter, json_date, - json_float, json_int, json_timestamp, jsonify, - make_list, retrieve_grid_params, - running_with_flask_debug, running_with_pytest, - sort_df_for_grid, swag_from) + find_selected_column, get_bool_arg, get_dtypes, + get_int_arg, get_str_arg, grid_columns, + grid_formatter, json_date, json_float, json_int, + json_timestamp, jsonify, make_list, + retrieve_grid_params, running_with_flask_debug, + running_with_pytest, sort_df_for_grid, swag_from) logger = getLogger(__name__) @@ -849,7 +849,7 @@ def get_correlations(data_id): return jsonify(dict(error=str(e), traceback=str(traceback.format_exc()))) -def build_chart(data, x, y, group_col=None, agg=None): +def build_chart(data, x, y, group_col=None, agg=None, allow_duplicates=False, **kwargs): """ Helper function to return data for 'chart-data' & 'correlations-ts' endpoints. Will return a dictionary of dictionaries (one for each series) which contain the data for the x & y axes of the chart as well as the minimum & @@ -870,11 +870,21 @@ def build_chart(data, x, y, group_col=None, agg=None): :type aggregation: str, optional :return: dict """ - x_col, y_col = str('x'), str('y') - if group_col is not None: - data = data[group_col + [x, y]].sort_values(group_col + [x]) - data.columns = group_col + [x_col, y_col] + def build_formatters(df): + cols = grid_columns(df) + overrides = {'D': lambda f, i, c: f.add_timestamp(i, c)} + data_f = grid_formatter(cols, overrides=overrides, nan_display=None) + overrides['F'] = lambda f, i, c: f.add_float(i, c, precision=2) + range_f = grid_formatter(cols, overrides=overrides, nan_display=None) + return data_f, range_f + + x_col = str('x') + y_cols = y.split(',') + if group_col is not None: + data = data[group_col + [x] + y_cols].sort_values(group_col + [x]) + y_cols = [str(y_col) for y_col in y_cols] + data.columns = group_col + [x_col] + y_cols if agg is not None: data = data.groupby(group_col + [x_col]) data = getattr(data, agg)().reset_index() @@ -885,35 +895,43 @@ def build_chart(data, x, y, group_col=None, agg=None): ' or else chart will be unreadable' ).format(', '.join(group_col), max_groups) raise Exception(msg) - f = grid_formatter( - grid_columns(data[[x_col, y_col]]), overrides={'D': lambda f, i, c: f.add_timestamp(i, c)}, nan_display=None + + data_f, range_f = build_formatters(data[[x_col] + y_cols]) + ret_data = dict( + data={}, + min={col: fmt(data[col].min(), None) for _, col, fmt in range_f.fmts if col in [x_col] + y_cols}, + max={col: fmt(data[col].max(), None) for _, col, fmt in range_f.fmts if col in [x_col] + y_cols}, ) - y_fmt = next((fmt for _, name, fmt in f.fmts if name == y_col), None) - ret_data = dict(data={}, min=y_fmt(data[y_col].min(), None), max=y_fmt(data[y_col].max(), None)) dtypes = get_dtypes(data) group_fmts = {c: find_dtype_formatter(dtypes[c]) for c in group_col} for group_val, grp in data.groupby(group_col): group_val = '/'.join([ group_fmts[gc](gv, as_string=True) for gv, gc in zip(make_list(group_val), group_col) ]) - ret_data['data'][group_val] = f.format_lists(grp) + ret_data['data'][group_val] = data_f.format_lists(grp) return ret_data - data = data[[x, y]].sort_values(x) - data.columns = [x_col, y_col] + data = data[[x] + y_cols].sort_values(x) + y_cols = [str(y_col) for y_col in y_cols] + data.columns = [x_col] + y_cols if agg is not None: - data = data.groupby(x_col) - data = getattr(data, agg)().reset_index() + if agg == 'rolling': + window, comp = map(kwargs.get, ['rolling_win', 'rolling_comp']) + data = data.set_index(x_col).rolling(window=window) + data = pd.DataFrame({c: getattr(data[c], comp)() for c in y_cols}) + data = data.reset_index() + else: + data = data.groupby(x_col) + data = getattr(data[y_cols], agg)().reset_index() - if any(data[x_col].duplicated()): + if not allow_duplicates and any(data[x_col].duplicated()): raise Exception('{} contains duplicates, please specify group or additional filtering'.format(x)) - f = grid_formatter( - grid_columns(data), overrides={'D': lambda f, i, c: f.add_timestamp(i, c)}, nan_display=None - ) - y_fmt = next((fmt for _, name, fmt in f.fmts if name == y_col), None) + if len(data) > 15000: + raise Exception('Dataset exceeds 15,000 records, cannot render. Please apply filter...') + data_f, range_f = build_formatters(data) ret_data = dict( - data={str('all'): f.format_lists(data)}, - min=y_fmt(data[y_col].min(), None), - max=y_fmt(data[y_col].max(), None) + data={str('all'): data_f.format_lists(data)}, + min={col: fmt(data[col].min(), None) for _, col, fmt in range_f.fmts if col in [x_col] + y_cols}, + max={col: fmt(data[col].max(), None) for _, col, fmt in range_f.fmts if col in [x_col] + y_cols}, ) return ret_data @@ -960,7 +978,12 @@ def get_chart_data(data_id): if group_col is not None: group_col = group_col.split(',') agg = get_str_arg(request, 'agg') - return jsonify(build_chart(data, x, y, group_col, agg)) + allow_duplicates = get_bool_arg(request, 'allowDupes') + window = get_int_arg(request, 'rollingWin') + comp = get_str_arg(request, 'rollingComp') + data = build_chart(data, x, y, group_col, agg, allow_duplicates, rolling_win=window, rolling_comp=comp) + data['success'] = True + return jsonify(data) except BaseException as e: return jsonify(dict(error=str(e), traceback=str(traceback.format_exc()))) @@ -1061,8 +1084,10 @@ def get_scatter(data_id): stats=stats, error='Dataset exceeds 15,000 records, cannot render scatter. Please apply filter...' ) - f = grid_formatter(grid_columns(data)) - data = f.format_dicts(data.itertuples()) - return jsonify(data=data, x=cols[0], y=cols[1], stats=stats) + data = build_chart(data, cols[0], str('{},index'.format(cols[1])), allow_duplicates=True) + data['x'] = cols[0] + data['y'] = cols[1] + data['stats'] = stats + return jsonify(data) except BaseException as e: return jsonify(dict(error=str(e), traceback=str(traceback.format_exc()))) diff --git a/static/__tests__/chartUtils-test.jsx b/static/__tests__/chartUtils-test.jsx index 08c345533..c7d375b47 100644 --- a/static/__tests__/chartUtils-test.jsx +++ b/static/__tests__/chartUtils-test.jsx @@ -11,6 +11,12 @@ const GRADIENT_FUNCS = [ "pointHoverBorderColor", ]; +function updateDataCfg(prop, data, cfg) { + cfg.data.all[prop] = data; + cfg.min[prop] = data[0]; + cfg.max[prop] = data[data.length - 1]; +} + describe("chartUtils tests", () => { beforeAll(() => { const mockChartUtils = withGlobalJquery(() => (ctx, cfg) => { @@ -30,12 +36,12 @@ describe("chartUtils tests", () => { const { colorScale } = require("../popups/correlations/correlationsUtils").default; const { gradientLinePlugin } = require("../chartUtils").default; - const plugin = gradientLinePlugin(colorScale, 1, -1); + const plugin = gradientLinePlugin(colorScale, "y-corr", 1, -1); const dataset = { data: [{ x: 0, y: 0 }] }; const chartInstance = { data: { datasets: [dataset] }, scales: { - "y-axis-0": { + "y-corr": { getPixelForValue: px => px, }, }, @@ -83,4 +89,203 @@ describe("chartUtils tests", () => { _.forEach(GRADIENT_FUNCS, f => t.ok(_.isFunction(dataset[f].addColorStop), "should set gradients")); done(); }); + + test("chartUtils: testing timestampLabel", done => { + const { timestampLabel } = require("../chartUtils").default; + + t.equal(timestampLabel(1514782800000), "2018-01-01", "should return date string"); + const tsLabel = timestampLabel(1514786400000); + t.ok(tsLabel === "2018-01-01 1:00:00 am", "should return timestamp string"); + done(); + }); + + test("chartUtils: testing createLineCfg with date axes", done => { + const { createLineCfg } = require("../chartUtils").default; + + const data = { + data: { + all: { + y: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], + x: [1514782800000, 1514786400000, 1514790000000, 1514793600000, 1514797200000, 1514800800000], + }, + }, + min: { x: 1514782800000, y: 0.1 }, + max: { x: 1514800800000, y: 0.6 }, + x: "x", + y: "y", + }; + const columns = [ + { name: "x", dtype: "datetime64[ns]" }, + { name: "y", dtype: "float64" }, + ]; + let cfg = createLineCfg(data, { + columns, + x: "x", + y: "y", + configHandler: cfg => cfg, + }); + t.deepEqual( + cfg.options.scales.xAxes[0].time, + { unit: "hour", stepSize: 4, displayFormats: { hour: "YYYYMMDD hA" } }, + "should build hourly config" + ); + t.equal(cfg.options.tooltips.callbacks.label({ yLabel: 0.1, datasetIndex: 0 }, cfg.data), "0.1"); + updateDataCfg( + "x", + [1578200400000, 1578805200000, 1579410000000, 1580014800000, 1580619600000, 1581224400000], + data + ); + cfg = createLineCfg(data, { + columns, + x: "x", + y: "y", + configHandler: cfg => cfg, + }); + t.deepEqual( + cfg.options.scales.xAxes[0].time, + { unit: "day", stepSize: 1, displayFormats: { day: "YYYYMMDD" } }, + "should build daily config" + ); + updateDataCfg( + "x", + [1578200400000, 1578805200000, 1579410000000, 1592107200000, 1592712000000, 1593316800000], + data + ); + cfg = createLineCfg(data, { + columns, + x: "x", + y: "y", + configHandler: cfg => cfg, + }); + t.deepEqual( + cfg.options.scales.xAxes[0].time, + { unit: "week", stepSize: 1, displayFormats: { week: "YYYYMMDD" } }, + "should build weekly config" + ); + updateDataCfg( + "x", + [1580446800000, 1582952400000, 1585627200000, 1635652800000, 1638248400000, 1640926800000], + data + ); + cfg = createLineCfg(data, { + columns, + x: "x", + y: "y", + configHandler: cfg => cfg, + }); + t.deepEqual( + cfg.options.scales.xAxes[0].time, + { unit: "month", stepSize: 1, displayFormats: { month: "YYYYMMDD" } }, + "should build monthly config" + ); + updateDataCfg( + "x", + [1585627200000, 1593489600000, 1601438400000, 1719720000000, 1727668800000, 1735621200000], + data + ); + cfg = createLineCfg(data, { + columns, + x: "x", + y: "y", + configHandler: cfg => cfg, + }); + t.deepEqual( + cfg.options.scales.xAxes[0].time, + { unit: "quarter", stepSize: 1, displayFormats: { quarter: "YYYYMMDD" } }, + "should build quarterly config" + ); + updateDataCfg( + "x", + [1609390800000, 1640926800000, 1672462800000, 1988082000000, 2019618000000, 2051154000000], + data + ); + cfg = createLineCfg(data, { + columns, + x: "x", + y: "y", + configHandler: cfg => cfg, + }); + t.deepEqual( + cfg.options.scales.xAxes[0].time, + { unit: "year", stepSize: 1, displayFormats: { year: "YYYYMMDD" } }, + "should build yearly config" + ); + done(); + }); + + test("chartUtils: testing createLineCfg with date y-axis", done => { + const { createLineCfg, createScatterCfg } = require("../chartUtils").default; + + const data = { + data: { + all: { + x: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], + y: [1514782800000, 1514786400000, 1514790000000, 1514793600000, 1514797200000, 1514800800000], + }, + }, + min: { y: 1514782800000, x: 0.1 }, + max: { y: 1514800800000, x: 0.6 }, + x: "x", + y: "y", + }; + const columns = [ + { name: "y", dtype: "datetime64[ns]" }, + { name: "x", dtype: "float64" }, + ]; + let cfg = createLineCfg(data, { + columns, + x: "x", + y: "y", + configHandler: cfg => cfg, + }); + t.deepEqual( + cfg.options.scales.yAxes[0].time, + { unit: "hour", stepSize: 4, displayFormats: { hour: "YYYYMMDD hA" } }, + "should build hourly config for y-axis" + ); + cfg = createScatterCfg(data, { + columns, + x: "x", + y: "y", + configHandler: cfg => cfg, + }); + t.ok(cfg.data.datasets[0].data[0].y == 1514782800000); + done(); + }); + + test("chartUtils: testing buildTicks", done => { + const { buildTicks } = require("../chartUtils").default; + const range = { min: { y: 0.1 }, max: { y: 0.6 } }; + t.deepEqual(buildTicks("y", range, true), { min: 0.095, max: 0.605 }, "should build padded ticks"); + done(); + }); + + test("chartUtils: testing buildSeries", done => { + const { createLineCfg } = require("../chartUtils").default; + + const series = { + y: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6], + x: [1514782800000, 1514786400000, 1514790000000, 1514793600000, 1514797200000, 1514800800000], + }; + const data = { + data: { series1: series, series2: series }, + min: { x: 1514782800000, y: 0.1 }, + max: { x: 1514800800000, y: 0.6 }, + x: "x", + y: "y", + }; + const columns = [ + { name: "x", dtype: "datetime64[ns]" }, + { name: "y", dtype: "float64" }, + ]; + const cfg = createLineCfg(data, { + columns, + x: "x", + y: "y", + configHandler: cfg => cfg, + }); + t.equal(cfg.data.datasets[0].label, "series1", "should construct series label"); + t.equal(cfg.options.tooltips.callbacks.label({ yLabel: 0.1, datasetIndex: 0 }, cfg.data), "series1: 0.1"); + done(); + }); }); diff --git a/static/__tests__/data/charts-grouped.json b/static/__tests__/data/charts-grouped.json index 8e8719558..aa7d54700 100644 --- a/static/__tests__/data/charts-grouped.json +++ b/static/__tests__/data/charts-grouped.json @@ -11,1446 +11,9 @@ 1545627600000, 1545714000000, 1545800400000, - 1545886800000, - 1545973200000, - 1546059600000, - 1546146000000, - 1546232400000, - 1546318800000, - 1546405200000, - 1546491600000, - 1546578000000, - 1546664400000, - 1546750800000, - 1546837200000, - 1546923600000, - 1547010000000, - 1547096400000, - 1547182800000, - 1547269200000, - 1547355600000, - 1547442000000, - 1547528400000, - 1547614800000, - 1547701200000, - 1547787600000, - 1547874000000, - 1547960400000, - 1548046800000, - 1548133200000, - 1548219600000, - 1548306000000, - 1548392400000, - 1548478800000, - 1548565200000, - 1548651600000, - 1548738000000, - 1548824400000, - 1548910800000, - 1548997200000, - 1549083600000, - 1549170000000, - 1549256400000, - 1549342800000, - 1549429200000, - 1549515600000, - 1549602000000, - 1549688400000, - 1549774800000, - 1549861200000, - 1549947600000, - 1550034000000, - 1550120400000, - 1550206800000, - 1550293200000, - 1550379600000, - 1550466000000, - 1550552400000, - 1550638800000, - 1550725200000, - 1550811600000, - 1550898000000, - 1550984400000, - 1551070800000, - 1551157200000, - 1551243600000, - 1551330000000, - 1551416400000, - 1551502800000, - 1551589200000, - 1551675600000, - 1551762000000, - 1551848400000, - 1551934800000, - 1552021200000, - 1552107600000, - 1552194000000, - 1552276800000, - 1552363200000, - 1552449600000, - 1552536000000, - 1552622400000, - 1552708800000, - 1552795200000, - 1552881600000, - 1552968000000, - 1553054400000, - 1553140800000, - 1553227200000, - 1553313600000, - 1553400000000, - 1553486400000, - 1553572800000, - 1553659200000, - 1553745600000, - 1553832000000, - 1553918400000, - 1554004800000, - 1554091200000, - 1554177600000, - 1554264000000, - 1554350400000, - 1554436800000, - 1554523200000, - 1554609600000, - 1554696000000, - 1554782400000, - 1554868800000, - 1554955200000, - 1555041600000, - 1555128000000, - 1555214400000, - 1555300800000, - 1555387200000, - 1555473600000, - 1555560000000, - 1555646400000, - 1555732800000, - 1555819200000, - 1555905600000, - 1555992000000, - 1556078400000, - 1556164800000, - 1556251200000, - 1556337600000, - 1556424000000, - 1556510400000, - 1556596800000, - 1556683200000, - 1556769600000, - 1556856000000, - 1556942400000, - 1557028800000, - 1557115200000, - 1557201600000, - 1557288000000, - 1557374400000, - 1557460800000, - 1557547200000, - 1557633600000, - 1557720000000, - 1557806400000, - 1557892800000, - 1557979200000, - 1558065600000, - 1558152000000, - 1558238400000, - 1558324800000, - 1558411200000, - 1558497600000, - 1558584000000, - 1558670400000, - 1558756800000, - 1558843200000, - 1558929600000, - 1559016000000, - 1559102400000, - 1559188800000, - 1559275200000, - 1559361600000, - 1559448000000, - 1559534400000, - 1559620800000, - 1559707200000, - 1559793600000, - 1559880000000, - 1559966400000, - 1560052800000, - 1560139200000, - 1560225600000, - 1560312000000, - 1560398400000, - 1560484800000, - 1560571200000, - 1560657600000, - 1560744000000, - 1560830400000, - 1560916800000, - 1561003200000, - 1561089600000, - 1561176000000, - 1561262400000, - 1561348800000, - 1561435200000, - 1561521600000, - 1561608000000, - 1561694400000, - 1561780800000, - 1561867200000, - 1561953600000, - 1562040000000, - 1562126400000, - 1562212800000, - 1562299200000, - 1562385600000, - 1562472000000, - 1562558400000, - 1562644800000, - 1562731200000, - 1562817600000, - 1562904000000, - 1562990400000, - 1563076800000, - 1563163200000, - 1563249600000, - 1563336000000, - 1563422400000, - 1563508800000, - 1563595200000, - 1563681600000, - 1563768000000, - 1563854400000, - 1563940800000, - 1564027200000, - 1564113600000, - 1564200000000, - 1564286400000, - 1564372800000, - 1564459200000, - 1564545600000, - 1564632000000, - 1564718400000, - 1564804800000, - 1564891200000, - 1564977600000, - 1565064000000, - 1565150400000, - 1565236800000, - 1565323200000, - 1565409600000, - 1565496000000, - 1565582400000, - 1565668800000, - 1565755200000, - 1565841600000, - 1565928000000, - 1566014400000, - 1566100800000, - 1566187200000, - 1566273600000, - 1566360000000, - 1566446400000, - 1566532800000, - 1566619200000, - 1566705600000, - 1566792000000, - 1566878400000, - 1566964800000, - 1567051200000, - 1567137600000, - 1567224000000, - 1567310400000, - 1567396800000, - 1567483200000, - 1567569600000, - 1567656000000, - 1567742400000, - 1567828800000, - 1567915200000, - 1568001600000, - 1568088000000, - 1568174400000, - 1568260800000, - 1568347200000, - 1568433600000, - 1568520000000, - 1568606400000, - 1568692800000, - 1568779200000, - 1568865600000, - 1568952000000, - 1569038400000, - 1569124800000, - 1569211200000, - 1569297600000, - 1569384000000, - 1569470400000, - 1569556800000, - 1569643200000, - 1569729600000, - 1569816000000, - 1569902400000, - 1569988800000, - 1570075200000, - 1570161600000, - 1570248000000, - 1570334400000, - 1570420800000, - 1570507200000, - 1570593600000, - 1570680000000, - 1570766400000, - 1570852800000, - 1570939200000, - 1571025600000, - 1571112000000, - 1571198400000, - 1571284800000, - 1571371200000, - 1571457600000, - 1571544000000, - 1571630400000, - 1571716800000, - 1571803200000, - 1571889600000, - 1571976000000, - 1572062400000, - 1572148800000, - 1572235200000, - 1572321600000, - 1572408000000, - 1572494400000, - 1572580800000, - 1572667200000, - 1572753600000, - 1572843600000, - 1572930000000, - 1573016400000, - 1573102800000, - 1573189200000, - 1573275600000, - 1573362000000, - 1573448400000, - 1573534800000, - 1573621200000, - 1573707600000, - 1573794000000, - 1573880400000, - 1573966800000, - 1574053200000, - 1574139600000, - 1574226000000, - 1574312400000, - 1574398800000, - 1574485200000, - 1574571600000, - 1574658000000, - 1574744400000, - 1574830800000, - 1574917200000, - 1575003600000, - 1575090000000, - 1575176400000, - 1575262800000, - 1575349200000, - 1575435600000, - 1575522000000, - 1575608400000, - 1575694800000, - 1575781200000, - 1575867600000, - 1575954000000, - 1576040400000, - 1576126800000, - 1576213200000, - 1576299600000, - 1576386000000, - 1576472400000, - 1576558800000 + 1545886800000 ], - "y": [ - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41 - ] - }, - "val2": { - "x": [ - 1545109200000, - 1545195600000, - 1545282000000, - 1545368400000, - 1545454800000, - 1545541200000, - 1545627600000, - 1545714000000, - 1545800400000, - 1545886800000, - 1545973200000, - 1546059600000, - 1546146000000, - 1546232400000, - 1546318800000, - 1546405200000, - 1546491600000, - 1546578000000, - 1546664400000, - 1546750800000, - 1546837200000, - 1546923600000, - 1547010000000, - 1547096400000, - 1547182800000, - 1547269200000, - 1547355600000, - 1547442000000, - 1547528400000, - 1547614800000, - 1547701200000, - 1547787600000, - 1547874000000, - 1547960400000, - 1548046800000, - 1548133200000, - 1548219600000, - 1548306000000, - 1548392400000, - 1548478800000, - 1548565200000, - 1548651600000, - 1548738000000, - 1548824400000, - 1548910800000, - 1548997200000, - 1549083600000, - 1549170000000, - 1549256400000, - 1549342800000, - 1549429200000, - 1549515600000, - 1549602000000, - 1549688400000, - 1549774800000, - 1549861200000, - 1549947600000, - 1550034000000, - 1550120400000, - 1550206800000, - 1550293200000, - 1550379600000, - 1550466000000, - 1550552400000, - 1550638800000, - 1550725200000, - 1550811600000, - 1550898000000, - 1550984400000, - 1551070800000, - 1551157200000, - 1551243600000, - 1551330000000, - 1551416400000, - 1551502800000, - 1551589200000, - 1551675600000, - 1551762000000, - 1551848400000, - 1551934800000, - 1552021200000, - 1552107600000, - 1552194000000, - 1552276800000, - 1552363200000, - 1552449600000, - 1552536000000, - 1552622400000, - 1552708800000, - 1552795200000, - 1552881600000, - 1552968000000, - 1553054400000, - 1553140800000, - 1553227200000, - 1553313600000, - 1553400000000, - 1553486400000, - 1553572800000, - 1553659200000, - 1553745600000, - 1553832000000, - 1553918400000, - 1554004800000, - 1554091200000, - 1554177600000, - 1554264000000, - 1554350400000, - 1554436800000, - 1554523200000, - 1554609600000, - 1554696000000, - 1554782400000, - 1554868800000, - 1554955200000, - 1555041600000, - 1555128000000, - 1555214400000, - 1555300800000, - 1555387200000, - 1555473600000, - 1555560000000, - 1555646400000, - 1555732800000, - 1555819200000, - 1555905600000, - 1555992000000, - 1556078400000, - 1556164800000, - 1556251200000, - 1556337600000, - 1556424000000, - 1556510400000, - 1556596800000, - 1556683200000, - 1556769600000, - 1556856000000, - 1556942400000, - 1557028800000, - 1557115200000, - 1557201600000, - 1557288000000, - 1557374400000, - 1557460800000, - 1557547200000, - 1557633600000, - 1557720000000, - 1557806400000, - 1557892800000, - 1557979200000, - 1558065600000, - 1558152000000, - 1558238400000, - 1558324800000, - 1558411200000, - 1558497600000, - 1558584000000, - 1558670400000, - 1558756800000, - 1558843200000, - 1558929600000, - 1559016000000, - 1559102400000, - 1559188800000, - 1559275200000, - 1559361600000, - 1559448000000, - 1559534400000, - 1559620800000, - 1559707200000, - 1559793600000, - 1559880000000, - 1559966400000, - 1560052800000, - 1560139200000, - 1560225600000, - 1560312000000, - 1560398400000, - 1560484800000, - 1560571200000, - 1560657600000, - 1560744000000, - 1560830400000, - 1560916800000, - 1561003200000, - 1561089600000, - 1561176000000, - 1561262400000, - 1561348800000, - 1561435200000, - 1561521600000, - 1561608000000, - 1561694400000, - 1561780800000, - 1561867200000, - 1561953600000, - 1562040000000, - 1562126400000, - 1562212800000, - 1562299200000, - 1562385600000, - 1562472000000, - 1562558400000, - 1562644800000, - 1562731200000, - 1562817600000, - 1562904000000, - 1562990400000, - 1563076800000, - 1563163200000, - 1563249600000, - 1563336000000, - 1563422400000, - 1563508800000, - 1563595200000, - 1563681600000, - 1563768000000, - 1563854400000, - 1563940800000, - 1564027200000, - 1564113600000, - 1564200000000, - 1564286400000, - 1564372800000, - 1564459200000, - 1564545600000, - 1564632000000, - 1564718400000, - 1564804800000, - 1564891200000, - 1564977600000, - 1565064000000, - 1565150400000, - 1565236800000, - 1565323200000, - 1565409600000, - 1565496000000, - 1565582400000, - 1565668800000, - 1565755200000, - 1565841600000, - 1565928000000, - 1566014400000, - 1566100800000, - 1566187200000, - 1566273600000, - 1566360000000, - 1566446400000, - 1566532800000, - 1566619200000, - 1566705600000, - 1566792000000, - 1566878400000, - 1566964800000, - 1567051200000, - 1567137600000, - 1567224000000, - 1567310400000, - 1567396800000, - 1567483200000, - 1567569600000, - 1567656000000, - 1567742400000, - 1567828800000, - 1567915200000, - 1568001600000, - 1568088000000, - 1568174400000, - 1568260800000, - 1568347200000, - 1568433600000, - 1568520000000, - 1568606400000, - 1568692800000, - 1568779200000, - 1568865600000, - 1568952000000, - 1569038400000, - 1569124800000, - 1569211200000, - 1569297600000, - 1569384000000, - 1569470400000, - 1569556800000, - 1569643200000, - 1569729600000, - 1569816000000, - 1569902400000, - 1569988800000, - 1570075200000, - 1570161600000, - 1570248000000, - 1570334400000, - 1570420800000, - 1570507200000, - 1570593600000, - 1570680000000, - 1570766400000, - 1570852800000, - 1570939200000, - 1571025600000, - 1571112000000, - 1571198400000, - 1571284800000, - 1571371200000, - 1571457600000, - 1571544000000, - 1571630400000, - 1571716800000, - 1571803200000, - 1571889600000, - 1571976000000, - 1572062400000, - 1572148800000, - 1572235200000, - 1572321600000, - 1572408000000, - 1572494400000, - 1572580800000, - 1572667200000, - 1572753600000, - 1572843600000, - 1572930000000, - 1573016400000, - 1573102800000, - 1573189200000, - 1573275600000, - 1573362000000, - 1573448400000, - 1573534800000, - 1573621200000, - 1573707600000, - 1573794000000, - 1573880400000, - 1573966800000, - 1574053200000, - 1574139600000, - 1574226000000, - 1574312400000, - 1574398800000, - 1574485200000, - 1574571600000, - 1574658000000, - 1574744400000, - 1574830800000, - 1574917200000, - 1575003600000, - 1575090000000, - 1575176400000, - 1575262800000, - 1575349200000, - 1575435600000, - 1575522000000, - 1575608400000, - 1575694800000, - 1575781200000, - 1575867600000, - 1575954000000, - 1576040400000, - 1576126800000, - 1576213200000, - 1576299600000, - 1576386000000, - 1576472400000, - 1576558800000 - ], - "y": [ - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, + "col1": [ 41, 41, 41, @@ -1460,6 +23,23 @@ 41, 41, 41, + 41 + ] + }, + "val2": { + "x": [ + 1545109200000, + 1545195600000, + 1545282000000, + 1545368400000, + 1545454800000, + 1545541200000, + 1545627600000, + 1545714000000, + 1545800400000, + 1545886800000 + ], + "col1": [ 41, 41, 41, @@ -1473,6 +53,8 @@ ] } }, - "max": 41, - "min": 41 + "max": {"col1": 41, "col4": 1545109200000}, + "min": {"col1": 41, "col4": 1545800400000}, + "x": "col4", + "y": "col1" } \ No newline at end of file diff --git a/static/__tests__/data/charts.json b/static/__tests__/data/charts.json index 6d7a99d60..9e9411f91 100644 --- a/static/__tests__/data/charts.json +++ b/static/__tests__/data/charts.json @@ -10,720 +10,9 @@ 1545541200000, 1545627600000, 1545714000000, - 1545800400000, - 1545886800000, - 1545973200000, - 1546059600000, - 1546146000000, - 1546232400000, - 1546318800000, - 1546405200000, - 1546491600000, - 1546578000000, - 1546664400000, - 1546750800000, - 1546837200000, - 1546923600000, - 1547010000000, - 1547096400000, - 1547182800000, - 1547269200000, - 1547355600000, - 1547442000000, - 1547528400000, - 1547614800000, - 1547701200000, - 1547787600000, - 1547874000000, - 1547960400000, - 1548046800000, - 1548133200000, - 1548219600000, - 1548306000000, - 1548392400000, - 1548478800000, - 1548565200000, - 1548651600000, - 1548738000000, - 1548824400000, - 1548910800000, - 1548997200000, - 1549083600000, - 1549170000000, - 1549256400000, - 1549342800000, - 1549429200000, - 1549515600000, - 1549602000000, - 1549688400000, - 1549774800000, - 1549861200000, - 1549947600000, - 1550034000000, - 1550120400000, - 1550206800000, - 1550293200000, - 1550379600000, - 1550466000000, - 1550552400000, - 1550638800000, - 1550725200000, - 1550811600000, - 1550898000000, - 1550984400000, - 1551070800000, - 1551157200000, - 1551243600000, - 1551330000000, - 1551416400000, - 1551502800000, - 1551589200000, - 1551675600000, - 1551762000000, - 1551848400000, - 1551934800000, - 1552021200000, - 1552107600000, - 1552194000000, - 1552276800000, - 1552363200000, - 1552449600000, - 1552536000000, - 1552622400000, - 1552708800000, - 1552795200000, - 1552881600000, - 1552968000000, - 1553054400000, - 1553140800000, - 1553227200000, - 1553313600000, - 1553400000000, - 1553486400000, - 1553572800000, - 1553659200000, - 1553745600000, - 1553832000000, - 1553918400000, - 1554004800000, - 1554091200000, - 1554177600000, - 1554264000000, - 1554350400000, - 1554436800000, - 1554523200000, - 1554609600000, - 1554696000000, - 1554782400000, - 1554868800000, - 1554955200000, - 1555041600000, - 1555128000000, - 1555214400000, - 1555300800000, - 1555387200000, - 1555473600000, - 1555560000000, - 1555646400000, - 1555732800000, - 1555819200000, - 1555905600000, - 1555992000000, - 1556078400000, - 1556164800000, - 1556251200000, - 1556337600000, - 1556424000000, - 1556510400000, - 1556596800000, - 1556683200000, - 1556769600000, - 1556856000000, - 1556942400000, - 1557028800000, - 1557115200000, - 1557201600000, - 1557288000000, - 1557374400000, - 1557460800000, - 1557547200000, - 1557633600000, - 1557720000000, - 1557806400000, - 1557892800000, - 1557979200000, - 1558065600000, - 1558152000000, - 1558238400000, - 1558324800000, - 1558411200000, - 1558497600000, - 1558584000000, - 1558670400000, - 1558756800000, - 1558843200000, - 1558929600000, - 1559016000000, - 1559102400000, - 1559188800000, - 1559275200000, - 1559361600000, - 1559448000000, - 1559534400000, - 1559620800000, - 1559707200000, - 1559793600000, - 1559880000000, - 1559966400000, - 1560052800000, - 1560139200000, - 1560225600000, - 1560312000000, - 1560398400000, - 1560484800000, - 1560571200000, - 1560657600000, - 1560744000000, - 1560830400000, - 1560916800000, - 1561003200000, - 1561089600000, - 1561176000000, - 1561262400000, - 1561348800000, - 1561435200000, - 1561521600000, - 1561608000000, - 1561694400000, - 1561780800000, - 1561867200000, - 1561953600000, - 1562040000000, - 1562126400000, - 1562212800000, - 1562299200000, - 1562385600000, - 1562472000000, - 1562558400000, - 1562644800000, - 1562731200000, - 1562817600000, - 1562904000000, - 1562990400000, - 1563076800000, - 1563163200000, - 1563249600000, - 1563336000000, - 1563422400000, - 1563508800000, - 1563595200000, - 1563681600000, - 1563768000000, - 1563854400000, - 1563940800000, - 1564027200000, - 1564113600000, - 1564200000000, - 1564286400000, - 1564372800000, - 1564459200000, - 1564545600000, - 1564632000000, - 1564718400000, - 1564804800000, - 1564891200000, - 1564977600000, - 1565064000000, - 1565150400000, - 1565236800000, - 1565323200000, - 1565409600000, - 1565496000000, - 1565582400000, - 1565668800000, - 1565755200000, - 1565841600000, - 1565928000000, - 1566014400000, - 1566100800000, - 1566187200000, - 1566273600000, - 1566360000000, - 1566446400000, - 1566532800000, - 1566619200000, - 1566705600000, - 1566792000000, - 1566878400000, - 1566964800000, - 1567051200000, - 1567137600000, - 1567224000000, - 1567310400000, - 1567396800000, - 1567483200000, - 1567569600000, - 1567656000000, - 1567742400000, - 1567828800000, - 1567915200000, - 1568001600000, - 1568088000000, - 1568174400000, - 1568260800000, - 1568347200000, - 1568433600000, - 1568520000000, - 1568606400000, - 1568692800000, - 1568779200000, - 1568865600000, - 1568952000000, - 1569038400000, - 1569124800000, - 1569211200000, - 1569297600000, - 1569384000000, - 1569470400000, - 1569556800000, - 1569643200000, - 1569729600000, - 1569816000000, - 1569902400000, - 1569988800000, - 1570075200000, - 1570161600000, - 1570248000000, - 1570334400000, - 1570420800000, - 1570507200000, - 1570593600000, - 1570680000000, - 1570766400000, - 1570852800000, - 1570939200000, - 1571025600000, - 1571112000000, - 1571198400000, - 1571284800000, - 1571371200000, - 1571457600000, - 1571544000000, - 1571630400000, - 1571716800000, - 1571803200000, - 1571889600000, - 1571976000000, - 1572062400000, - 1572148800000, - 1572235200000, - 1572321600000, - 1572408000000, - 1572494400000, - 1572580800000, - 1572667200000, - 1572753600000, - 1572843600000, - 1572930000000, - 1573016400000, - 1573102800000, - 1573189200000, - 1573275600000, - 1573362000000, - 1573448400000, - 1573534800000, - 1573621200000, - 1573707600000, - 1573794000000, - 1573880400000, - 1573966800000, - 1574053200000, - 1574139600000, - 1574226000000, - 1574312400000, - 1574398800000, - 1574485200000, - 1574571600000, - 1574658000000, - 1574744400000, - 1574830800000, - 1574917200000, - 1575003600000, - 1575090000000, - 1575176400000, - 1575262800000, - 1575349200000, - 1575435600000, - 1575522000000, - 1575608400000, - 1575694800000, - 1575781200000, - 1575867600000, - 1575954000000, - 1576040400000, - 1576126800000, - 1576213200000, - 1576299600000, - 1576386000000, - 1576472400000, - 1576558800000 + 1545800400000 ], - "y": [ - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, - 41, + "col1": [ 41, 41, 41, @@ -737,6 +26,8 @@ ] } }, - "max": 41, - "min": 41 + "max": {"col1": 41, "col4": 1545109200000}, + "min": {"col1": 41, "col4": 1545800400000}, + "x": "col4", + "y": "col1" } \ No newline at end of file diff --git a/static/__tests__/data/correlations-ts.json b/static/__tests__/data/correlations-ts.json index b6d5d89a8..f1dbf1d47 100644 --- a/static/__tests__/data/correlations-ts.json +++ b/static/__tests__/data/correlations-ts.json @@ -18,7 +18,7 @@ 1546318800000, 1546405200000 ], - "y": [ + "corr": [ 0.194918, -0.201896, 0.115315, @@ -38,5 +38,7 @@ } }, "max": 0.40548625523544046, - "min": -0.5101782784596804 + "min": -0.5101782784596804, + "x": "x", + "y": "corr" } \ No newline at end of file diff --git a/static/__tests__/data/scatter.json b/static/__tests__/data/scatter.json index b93e7f8dc..7ab1e0ccd 100644 --- a/static/__tests__/data/scatter.json +++ b/static/__tests__/data/scatter.json @@ -1,16 +1,11 @@ { - "data": [ - {"col": 1.4, "col2": 1.5, "index": 0}, - {"col": 2.4, "col2": 1.5, "index": 1}, - {"col": 3.4, "col2": 2.1, "index": 2}, - {"col": 4.4, "col2": 1.3, "index": 3}, - {"col": 9.1, "col2": -3.7, "index": 4}, - {"col": 0.2, "col2": 6.6, "index": 5}, - {"col": -1.3, "col2": 1.9, "index": 6}, - {"col": 5.6, "col2": 8.8, "index": 7}, - {"col": 3.7, "col2": 7.2, "index": 8}, - {"col": 4.2, "col2": 3.0, "index": 9} - ], + "data": { + "all": { + "x": [1.4, 2.4, 3.4, 4.4, 9.2, 0.2, -1.3, 5.6, 3.7, 4.2], + "col2": [1.5, 1.5, 2.1, 1.3, -3.7, 6.6, 1.9, 8.8, 7.2, 3.0], + "index": [0,1,2,3,4,5,6,7,8,9] + } + }, "stats": { "correlated": 10, "only_in_s0": 0, @@ -19,5 +14,15 @@ "spearman": 0.01647169200912469 }, "x": "col1", - "y": "col2" + "y": "col2", + "max": { + "index": 9, + "col2": 8.8, + "x": 9.2 + }, + "min": { + "index": 0, + "col2": -3.7, + "x": -1.3 + } } \ No newline at end of file diff --git a/static/__tests__/dtale/DataViewer-base-test.jsx b/static/__tests__/dtale/DataViewer-base-test.jsx index f415fd0c6..ab89adbb5 100644 --- a/static/__tests__/dtale/DataViewer-base-test.jsx +++ b/static/__tests__/dtale/DataViewer-base-test.jsx @@ -204,7 +204,7 @@ describe("DataViewer tests", () => { result.update(); t.deepEqual( result.find(".main-grid div.headerCell").map(hc => hc.text()), - ["▲col4", "col1", "col2", "col3"], + ["▼col4", "col1", "col2", "col3"], "should move col4 to front of main grid" ); @@ -235,7 +235,7 @@ describe("DataViewer tests", () => { .first() .find("div.headerCell") .map(hc => hc.text()), - ["▲col4", "col1", "col2", "col3"], + ["▼col4", "col1", "col2", "col3"], "should move col4 back into main grid" ); diff --git a/static/__tests__/dtale/DataViewer-correlations-test.jsx b/static/__tests__/dtale/DataViewer-correlations-test.jsx index 97837cc12..8c1ebc0e1 100644 --- a/static/__tests__/dtale/DataViewer-correlations-test.jsx +++ b/static/__tests__/dtale/DataViewer-correlations-test.jsx @@ -90,7 +90,7 @@ describe("DataViewer tests", () => { const layoutObj = { chart: tsChart, scales: { - "y-axis-0": { + "y-corr": { getPixelForValue: px => px, }, }, diff --git a/static/__tests__/iframe/DataViewer-base-test.jsx b/static/__tests__/iframe/DataViewer-base-test.jsx index 98382b7eb..b7649d050 100644 --- a/static/__tests__/iframe/DataViewer-base-test.jsx +++ b/static/__tests__/iframe/DataViewer-base-test.jsx @@ -201,7 +201,7 @@ describe("DataViewer iframe tests", () => { clickColMenuButton(result, "Move To Front"); t.deepEqual( result.find(".main-grid div.headerCell").map(hc => hc.text()), - ["▼col4", "col1", "col2", "col3"], + ["▲col4", "col1", "col2", "col3"], "should move col4 to front of main grid" ); result @@ -235,7 +235,7 @@ describe("DataViewer iframe tests", () => { .first() .find("div.headerCell") .map(hc => hc.text()), - ["▼col4", "col1", "col2", "col3"], + ["▲col4", "col1", "col2", "col3"], "should move col4 back into main grid" ); diff --git a/static/__tests__/popups/ChartLabel-test.jsx b/static/__tests__/popups/ChartLabel-test.jsx new file mode 100644 index 000000000..d81567764 --- /dev/null +++ b/static/__tests__/popups/ChartLabel-test.jsx @@ -0,0 +1,19 @@ +import { mount } from "enzyme"; +import React from "react"; + +import ChartLabel from "../../popups/charts/ChartLabel"; +import * as t from "../jest-assertions"; + +describe("ChartLabel tests", () => { + test("ChartLabel rendering", done => { + const props = { + x: { value: "foo" }, + y: [{ value: "bar" }], + aggregation: "count", + }; + const result = mount(); + result.render(); + t.equal(result.text(), "Count of bar by foo", "should render label"); + done(); + }); +}); diff --git a/static/__tests__/popups/ChartsBody-test.jsx b/static/__tests__/popups/ChartsBody-test.jsx index 56330f089..dd74381b5 100644 --- a/static/__tests__/popups/ChartsBody-test.jsx +++ b/static/__tests__/popups/ChartsBody-test.jsx @@ -58,6 +58,8 @@ describe("ChartsBody tests", () => { setTimeout(() => { result.update(); t.ok(_.includes(result.html(), "Error test."), "should render error"); + result.setProps({ visible: false }); + t.equal(result.html(), null); done(); }, 200); }); diff --git a/static/__tests__/popups/Correlations-test.jsx b/static/__tests__/popups/Correlations-test.jsx index 231a01707..b458fc2bb 100644 --- a/static/__tests__/popups/Correlations-test.jsx +++ b/static/__tests__/popups/Correlations-test.jsx @@ -204,7 +204,7 @@ describe("Correlations tests", () => { { datasetIndex: 0, index: 0 }, scatterChart.data ); - t.deepEqual(label, ["col1: NaN", "col2: 1.5"], "should render label"); + t.deepEqual(label, ["col1: 1.4", "col2: 1.5"], "should render label"); scatterChart.cfg.options.onClick({}); const corr = result.instance(); diff --git a/static/__tests__/popups/Histogram-test.jsx b/static/__tests__/popups/Histogram-test.jsx index ebd8714bc..27c202179 100644 --- a/static/__tests__/popups/Histogram-test.jsx +++ b/static/__tests__/popups/Histogram-test.jsx @@ -94,11 +94,7 @@ describe("Histogram tests", () => { const result = mount(, { attachTo: document.getElementById("content"), }); - t.deepEqual( - result.find("option").map(o => o.text()), - ["5", "10", "20", "50"], - "Should render bins options" - ); + t.deepEqual(result.find("input").prop("value"), "20", "Should render default bins"); let chart = null; setTimeout(() => { @@ -111,7 +107,7 @@ describe("Histogram tests", () => { t.equal(xlabel, "Bin", "should display correct x-axis label"); const event = { target: { value: 50 } }; - result.find("select").simulate("change", event); + result.find("input").simulate("change", event); setTimeout(() => { result.update(); diff --git a/static/__tests__/popups/WordcloudBody-test.jsx b/static/__tests__/popups/WordcloudBody-test.jsx index 8f3ce8d43..921b9d364 100644 --- a/static/__tests__/popups/WordcloudBody-test.jsx +++ b/static/__tests__/popups/WordcloudBody-test.jsx @@ -60,22 +60,36 @@ describe("WordcloudBody tests", () => { test("WordcloudBody missing data", done => { const WordcloudBody = require("../../popups/charts/WordcloudBody").default; - const result = mount(, { + const result = mount(, { attachTo: document.getElementById("content"), }); result.update(); - t.notOk(result.html(), "shouldn't render anything"); + t.equal(result.html(), '
', "shouldn't render anything"); done(); }); test("WordcloudBody invalid chartType type", done => { const WordcloudBody = require("../../popups/charts/WordcloudBody").default; - const result = mount(, { + const result = mount(, { attachTo: document.getElementById("content"), }); result.update(); t.notOk(result.html(), "shouldn't render anything"); done(); }); + + test("WordcloudBody missing yProp data", done => { + const WordcloudBody = require("../../popups/charts/WordcloudBody").default; + + const result = mount( + , + { + attachTo: document.getElementById("content"), + } + ); + result.update(); + t.equal(result.html(), '
', "shouldn't render anything"); + done(); + }); }); diff --git a/static/__tests__/popups/window/Charts-bar-test.jsx b/static/__tests__/popups/window/Charts-bar-test.jsx new file mode 100644 index 000000000..59f62b982 --- /dev/null +++ b/static/__tests__/popups/window/Charts-bar-test.jsx @@ -0,0 +1,165 @@ +import qs from "querystring"; + +import { mount } from "enzyme"; +import _ from "lodash"; +import React from "react"; +import Select from "react-select"; + +import AxisEditor from "../../../popups/charts/AxisEditor"; +import mockPopsicle from "../../MockPopsicle"; +import * as t from "../../jest-assertions"; +import { buildInnerHTML, withGlobalJquery } from "../../test-utils"; + +const originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"); +const originalOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"); + +function updateChartType(result, cmp, chartType) { + result + .find(cmp) + .find(Select) + .first() + .instance() + .onChange({ value: chartType }); + result.update(); +} + +describe("Charts bar tests", () => { + beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", { + configurable: true, + value: 500, + }); + Object.defineProperty(HTMLElement.prototype, "offsetWidth", { + configurable: true, + value: 500, + }); + + const mockBuildLibs = withGlobalJquery(() => + mockPopsicle.mock(url => { + const urlParams = qs.parse(url.split("?")[1]); + if (urlParams.x === "error" && urlParams.y === "error2") { + return { data: {} }; + } + const { urlFetcher } = require("../../redux-test-utils").default; + return urlFetcher(url); + }) + ); + + const mockChartUtils = withGlobalJquery(() => (ctx, cfg) => { + const chartCfg = { ctx, cfg, data: cfg.data, destroyed: false }; + chartCfg.destroy = () => (chartCfg.destroyed = true); + chartCfg.getElementAtEvent = _evt => [{ _index: 0 }]; + chartCfg.update = _.noop; + chartCfg.options = { scales: { xAxes: [{}] } }; + return chartCfg; + }); + + const mockD3Cloud = withGlobalJquery(() => () => { + const cloudCfg = {}; + const propUpdate = prop => val => { + cloudCfg[prop] = val; + return cloudCfg; + }; + cloudCfg.size = propUpdate("size"); + cloudCfg.padding = propUpdate("padding"); + cloudCfg.words = propUpdate("words"); + cloudCfg.rotate = propUpdate("rotate"); + cloudCfg.spiral = propUpdate("spiral"); + cloudCfg.random = propUpdate("random"); + cloudCfg.text = propUpdate("text"); + cloudCfg.font = propUpdate("font"); + cloudCfg.fontStyle = propUpdate("fontStyle"); + cloudCfg.fontWeight = propUpdate("fontWeight"); + cloudCfg.fontSize = () => ({ + on: () => ({ start: _.noop }), + }); + return cloudCfg; + }); + + jest.mock("popsicle", () => mockBuildLibs); + jest.mock("d3-cloud", () => mockD3Cloud); + jest.mock("chart.js", () => mockChartUtils); + jest.mock("chartjs-plugin-zoom", () => ({})); + jest.mock("chartjs-chart-box-and-violin-plot/build/Chart.BoxPlot.js", () => ({})); + }); + + afterAll(() => { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", originalOffsetHeight); + Object.defineProperty(HTMLElement.prototype, "offsetWidth", originalOffsetWidth); + }); + + test("Charts: rendering", done => { + const Charts = require("../../../popups/charts/Charts").ReactCharts; + const ChartsBody = require("../../../popups/charts/ChartsBody").default; + buildInnerHTML({ settings: "" }); + const result = mount(, { + attachTo: document.getElementById("content"), + }); + + setTimeout(() => { + result.update(); + const filters = result.find(Charts).find(Select); + filters + .first() + .instance() + .onChange({ value: "col4" }); + filters + .at(1) + .instance() + .onChange([{ value: "col1" }]); + updateChartType(result, ChartsBody, "bar"); + result + .find(Charts) + .find("button") + .first() + .simulate("click"); + setTimeout(() => { + result.update(); + t.ok(result.find(ChartsBody).instance().state.charts.length == 1, "should render charts"); + const sortBtn = result + .find(ChartsBody) + .find("button") + .findWhere(b => b.text() === "Sort Bars") + .first(); + sortBtn.simulate("click"); + let axisEditor = result.find(AxisEditor).first(); + axisEditor.find("span.axis-select").simulate("click"); + axisEditor + .find("input") + .first() + .simulate("change", { target: { value: "40" } }); + axisEditor + .find("input") + .last() + .simulate("change", { target: { value: "42" } }); + axisEditor.instance().closeMenu(); + const chartObj = result.find(ChartsBody).instance().state.charts[0]; + t.deepEqual(chartObj.cfg.options.scales.yAxes[0].ticks, { + min: 40, + max: 42, + }); + axisEditor = result.find(AxisEditor).first(); + axisEditor.find("span.axis-select").simulate("click"); + axisEditor + .find("input") + .first() + .simulate("change", { target: { value: "40" } }); + axisEditor + .find("input") + .last() + .simulate("change", { target: { value: "a" } }); + axisEditor.instance().closeMenu(); + axisEditor = result.find(AxisEditor).first(); + t.equal(axisEditor.instance().state.errors[0], "col1 has invalid max!"); + axisEditor + .find("input") + .last() + .simulate("change", { target: { value: "39" } }); + axisEditor.instance().closeMenu(); + axisEditor = result.find(AxisEditor).first(); + t.equal(axisEditor.instance().state.errors[0], "col1 must have a min < max!"); + done(); + }, 400); + }, 600); + }); +}); diff --git a/static/__tests__/popups/window/Charts-multi-column-test.jsx b/static/__tests__/popups/window/Charts-multi-column-test.jsx new file mode 100644 index 000000000..bd2c770c3 --- /dev/null +++ b/static/__tests__/popups/window/Charts-multi-column-test.jsx @@ -0,0 +1,244 @@ +/* eslint max-statements: "off" */ +import qs from "querystring"; + +import { mount } from "enzyme"; +import _ from "lodash"; +import React from "react"; +import Select from "react-select"; + +import { Aggregations } from "../../../popups/charts/Aggregations"; +import mockPopsicle from "../../MockPopsicle"; +import * as t from "../../jest-assertions"; +import { buildInnerHTML, withGlobalJquery } from "../../test-utils"; + +const originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"); +const originalOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"); + +function updateChartType(result, cmp, chartType) { + result + .find(cmp) + .find(Select) + .first() + .instance() + .onChange({ value: chartType }); + result.update(); +} + +describe("Charts tests", () => { + beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", { + configurable: true, + value: 500, + }); + Object.defineProperty(HTMLElement.prototype, "offsetWidth", { + configurable: true, + value: 500, + }); + + const mockBuildLibs = withGlobalJquery(() => + mockPopsicle.mock(url => { + const urlParams = qs.parse(url.split("?")[1]); + if (urlParams.x === "error" && urlParams.y === "error2") { + return { data: {} }; + } + const { urlFetcher } = require("../../redux-test-utils").default; + return urlFetcher(url); + }) + ); + + const mockChartUtils = withGlobalJquery(() => (ctx, cfg) => { + const chartCfg = { ctx, cfg, data: cfg.data, destroyed: false }; + chartCfg.destroy = () => (chartCfg.destroyed = true); + chartCfg.getElementAtEvent = _evt => [{ _index: 0 }]; + chartCfg.update = _.noop; + chartCfg.options = { scales: { xAxes: [{}] } }; + return chartCfg; + }); + + const mockD3Cloud = withGlobalJquery(() => () => { + const cloudCfg = {}; + const propUpdate = prop => val => { + cloudCfg[prop] = val; + return cloudCfg; + }; + cloudCfg.size = propUpdate("size"); + cloudCfg.padding = propUpdate("padding"); + cloudCfg.words = propUpdate("words"); + cloudCfg.rotate = propUpdate("rotate"); + cloudCfg.spiral = propUpdate("spiral"); + cloudCfg.random = propUpdate("random"); + cloudCfg.text = propUpdate("text"); + cloudCfg.font = propUpdate("font"); + cloudCfg.fontStyle = propUpdate("fontStyle"); + cloudCfg.fontWeight = propUpdate("fontWeight"); + cloudCfg.fontSize = () => ({ + on: () => ({ start: _.noop }), + }); + return cloudCfg; + }); + + jest.mock("popsicle", () => mockBuildLibs); + jest.mock("d3-cloud", () => mockD3Cloud); + jest.mock("chart.js", () => mockChartUtils); + jest.mock("chartjs-plugin-zoom", () => ({})); + jest.mock("chartjs-chart-box-and-violin-plot/build/Chart.BoxPlot.js", () => ({})); + }); + + afterAll(() => { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", originalOffsetHeight); + Object.defineProperty(HTMLElement.prototype, "offsetWidth", originalOffsetWidth); + }); + + test("Charts: rendering", done => { + const Charts = require("../../../popups/charts/Charts").ReactCharts; + const ChartsBody = require("../../../popups/charts/ChartsBody").default; + const ReactWordcloud = require("react-wordcloud").default; + buildInnerHTML({ settings: "" }); + const result = mount(, { + attachTo: document.getElementById("content"), + }); + + setTimeout(() => { + result.update(); + let filters = result.find(Charts).find(Select); + filters + .first() + .instance() + .onChange({ value: "col4" }); + filters + .at(1) + .instance() + .onChange([{ value: "col1" }, { value: "col2" }]); + filters + .at(3) + .instance() + .onChange({ value: "rolling", label: "Rolling" }); + result.update(); + result + .find(Aggregations) + .find("input") + .at(1) + .simulate("change", { target: { value: "10" } }); + result + .find(Aggregations) + .find(Select) + .last() + .instance() + .onChange({ value: "corr", label: "Correlation" }); + result + .find(Charts) + .find("input.form-control") + .first() + .simulate("change", { target: { value: "col4 == '20181201'" } }); + result.update(); + result + .find(Charts) + .find("button") + .first() + .simulate("click"); + setTimeout(() => { + result.update(); + t.ok(result.find(ChartsBody).instance().state.charts.length == 1, "should render charts"); + t.ok( + _.endsWith( + result.find(Charts).instance().state.url, + "x=col4&y=col1%2Ccol2&query=col4%20%3D%3D%20'20181201'&agg=rolling&rollingWin=10&rollingComp=corr" + ), + "should update chart URL" + ); + result + .find(ChartsBody) + .instance() + .state.charts[0].cfg.options.onClick(); + result.update(); + t.deepEqual( + result.find(ChartsBody).instance().state.charts[0].options.scales.xAxes[0], + { ticks: { max: 1545800400000, min: 1545109200000 } }, + "should limit x-axis" + ); + const chartObj = result.find(ChartsBody).instance().state.charts[0]; + const tsTitle = chartObj.cfg.options.tooltips.callbacks.title([{ index: 0 }], chartObj.data); + t.ok(tsTitle === "2018-12-18", "should return timestamp in tooltip"); + result + .find(ChartsBody) + .instance() + .resetZoom(); + result.update(); + t.notOk( + result.find(ChartsBody).instance().state.charts[0].options.scales.xAxes[0].length == 0, + "should clear limited x-axis" + ); + updateChartType(result, ChartsBody, "bar"); + t.ok(result.find(ChartsBody).instance().state.charts[0].cfg.type === "bar"); + updateChartType(result, ChartsBody, "wordcloud"); + updateChartType(result, ChartsBody, "stacked"); + t.ok(result.find(ChartsBody).instance().state.charts[0].cfg.options.scales.xAxes[0].stacked); + updateChartType(result, ChartsBody, "scatter"); + t.ok(result.find(ChartsBody).instance().state.charts[0].cfg.type === "scatter"); + updateChartType(result, ChartsBody, "pie"); + t.ok(result.find(ChartsBody).instance().state.charts[0].cfg.type === "pie"); + filters = result.find(Charts).find(Select); + filters + .at(3) + .instance() + .onChange(null); + filters + .at(2) + .instance() + .onChange([{ value: "col1" }, { value: "col3" }]); + result + .find(Charts) + .find("button") + .first() + .simulate("click"); + setTimeout(() => { + result.update(); + updateChartType(result, ChartsBody, "line"); + let chartObj = result.find(ChartsBody).instance().state.charts[0]; + t.equal( + chartObj.cfg.options.tooltips.callbacks.label( + { xLabel: 1545973200000, yLabel: 1.123456, datasetIndex: 0 }, + chartObj.data + ), + "val1 - col1: 1.1235", + "should render tooltip label" + ); + updateChartType(result, ChartsBody, "wordcloud"); + updateChartType(result, ChartsBody, "line"); + result + .find(Charts) + .find("input") + .findWhere(i => i.prop("type") === "checkbox") + .first() + .simulate("change", { target: { checked: true } }); + result.update(); + t.ok(result.find(ChartsBody).instance().state.charts.length == 2, "should render multiple charts"); + chartObj = result.find(ChartsBody).instance().state.charts[0]; + t.equal( + chartObj.cfg.options.tooltips.callbacks.label( + { xLabel: 1545973200000, yLabel: 1.123456, datasetIndex: 0 }, + chartObj.data + ), + "col1: 1.1235", + "should render tooltip label" + ); + updateChartType(result, ChartsBody, "wordcloud"); + const wc = result.find(ReactWordcloud).first(); + t.ok(wc.props().callbacks.getWordTooltip({ fullText: "test", value: 5 }), "test (5)", "should show tooltip"); + const cb = result.find(ChartsBody).instance(); + t.notOk(cb.shouldComponentUpdate(cb.props, cb.state), "shouldn't update chart body"); + t.ok( + cb.shouldComponentUpdate(cb.props, _.assignIn({}, cb.state, { error: "test" })), + "should update chart body" + ); + t.ok(cb.shouldComponentUpdate(cb.props, _.assignIn({}, cb.state, { data: {} })), "should update chart body"); + t.notOk( + cb.shouldComponentUpdate(cb.props, _.assignIn({}, cb.state, { chart: true })), + "shouldn't update chart body" + ); + done(); + }, 400); + }, 400); + }, 600); + }); +}); diff --git a/static/__tests__/popups/window/Charts-rolling-test.jsx b/static/__tests__/popups/window/Charts-rolling-test.jsx new file mode 100644 index 000000000..30a2409b5 --- /dev/null +++ b/static/__tests__/popups/window/Charts-rolling-test.jsx @@ -0,0 +1,147 @@ +import qs from "querystring"; + +import { mount } from "enzyme"; +import _ from "lodash"; +import React from "react"; +import Select from "react-select"; + +import { Aggregations } from "../../../popups/charts/Aggregations"; +import mockPopsicle from "../../MockPopsicle"; +import * as t from "../../jest-assertions"; +import { buildInnerHTML, withGlobalJquery } from "../../test-utils"; + +const originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"); +const originalOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"); + +describe("Charts rolling tests", () => { + beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", { + configurable: true, + value: 500, + }); + Object.defineProperty(HTMLElement.prototype, "offsetWidth", { + configurable: true, + value: 500, + }); + + const mockBuildLibs = withGlobalJquery(() => + mockPopsicle.mock(url => { + const urlParams = qs.parse(url.split("?")[1]); + if (urlParams.x === "error" && urlParams.y === "error2") { + return { data: {} }; + } + const { urlFetcher } = require("../../redux-test-utils").default; + return urlFetcher(url); + }) + ); + + const mockChartUtils = withGlobalJquery(() => (ctx, cfg) => { + const chartCfg = { ctx, cfg, data: cfg.data, destroyed: false }; + chartCfg.destroy = () => (chartCfg.destroyed = true); + chartCfg.getElementAtEvent = _evt => [{ _index: 0 }]; + chartCfg.update = _.noop; + chartCfg.options = { scales: { xAxes: [{}] } }; + return chartCfg; + }); + + const mockD3Cloud = withGlobalJquery(() => () => { + const cloudCfg = {}; + const propUpdate = prop => val => { + cloudCfg[prop] = val; + return cloudCfg; + }; + cloudCfg.size = propUpdate("size"); + cloudCfg.padding = propUpdate("padding"); + cloudCfg.words = propUpdate("words"); + cloudCfg.rotate = propUpdate("rotate"); + cloudCfg.spiral = propUpdate("spiral"); + cloudCfg.random = propUpdate("random"); + cloudCfg.text = propUpdate("text"); + cloudCfg.font = propUpdate("font"); + cloudCfg.fontStyle = propUpdate("fontStyle"); + cloudCfg.fontWeight = propUpdate("fontWeight"); + cloudCfg.fontSize = () => ({ + on: () => ({ start: _.noop }), + }); + return cloudCfg; + }); + + jest.mock("popsicle", () => mockBuildLibs); + jest.mock("d3-cloud", () => mockD3Cloud); + jest.mock("chart.js", () => mockChartUtils); + jest.mock("chartjs-plugin-zoom", () => ({})); + jest.mock("chartjs-chart-box-and-violin-plot/build/Chart.BoxPlot.js", () => ({})); + }); + + afterAll(() => { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", originalOffsetHeight); + Object.defineProperty(HTMLElement.prototype, "offsetWidth", originalOffsetWidth); + }); + + test("Charts: rendering", done => { + const Charts = require("../../../popups/charts/Charts").ReactCharts; + buildInnerHTML({ settings: "" }); + const result = mount(, { + attachTo: document.getElementById("content"), + }); + + setTimeout(() => { + result.update(); + const filters = result.find(Charts).find(Select); + filters + .first() + .instance() + .onChange({ value: "col4" }); + filters + .at(1) + .instance() + .onChange([{ value: "col1" }]); + filters + .at(3) + .instance() + .onChange({ value: "rolling", label: "Rolling" }); + result.update(); + result + .find(Aggregations) + .find("input") + .at(1) + .simulate("change", { target: { value: "" } }); + result + .find(Aggregations) + .find(Select) + .last() + .instance() + .onChange({ value: "corr", label: "Correlation" }); + result + .find(Charts) + .find("button") + .first() + .simulate("click"); + setTimeout(() => { + result.update(); + t.equal(result.find(Charts).instance().state.error, "Aggregation (rolling) requires a window"); + result + .find(Aggregations) + .find("input") + .at(1) + .simulate("change", { target: { value: "10" } }); + result + .find(Aggregations) + .find(Select) + .last() + .instance() + .onChange(null); + result + .find(Charts) + .find("button") + .first() + .simulate("click"); + setTimeout(() => { + result.update(); + t.equal(result.find(Charts).instance().state.error, "Aggregation (rolling) requires a computation"); + done(); + }, 400); + }, 400); + }, 600); + }); +}); diff --git a/static/__tests__/popups/window/Charts-scatter-test.jsx b/static/__tests__/popups/window/Charts-scatter-test.jsx new file mode 100644 index 000000000..8797b4fad --- /dev/null +++ b/static/__tests__/popups/window/Charts-scatter-test.jsx @@ -0,0 +1,129 @@ +import qs from "querystring"; + +import { mount } from "enzyme"; +import _ from "lodash"; +import React from "react"; +import Select from "react-select"; + +import mockPopsicle from "../../MockPopsicle"; +import { buildInnerHTML, withGlobalJquery } from "../../test-utils"; + +const originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"); +const originalOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"); + +function updateChartType(result, cmp, chartType) { + result + .find(cmp) + .find(Select) + .first() + .instance() + .onChange({ value: chartType }); + result.update(); +} + +describe("Charts scatter tests", () => { + beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", { + configurable: true, + value: 500, + }); + Object.defineProperty(HTMLElement.prototype, "offsetWidth", { + configurable: true, + value: 500, + }); + + const mockBuildLibs = withGlobalJquery(() => + mockPopsicle.mock(url => { + const urlParams = qs.parse(url.split("?")[1]); + if (urlParams.x === "error" && urlParams.y === "error2") { + return { data: {} }; + } + const { urlFetcher } = require("../../redux-test-utils").default; + return urlFetcher(url); + }) + ); + + const mockChartUtils = withGlobalJquery(() => (ctx, cfg) => { + const chartCfg = { ctx, cfg, data: cfg.data, destroyed: false }; + chartCfg.destroy = () => (chartCfg.destroyed = true); + chartCfg.getElementAtEvent = _evt => [{ _index: 0 }]; + chartCfg.update = _.noop; + chartCfg.options = { scales: { xAxes: [{}] } }; + return chartCfg; + }); + + const mockD3Cloud = withGlobalJquery(() => () => { + const cloudCfg = {}; + const propUpdate = prop => val => { + cloudCfg[prop] = val; + return cloudCfg; + }; + cloudCfg.size = propUpdate("size"); + cloudCfg.padding = propUpdate("padding"); + cloudCfg.words = propUpdate("words"); + cloudCfg.rotate = propUpdate("rotate"); + cloudCfg.spiral = propUpdate("spiral"); + cloudCfg.random = propUpdate("random"); + cloudCfg.text = propUpdate("text"); + cloudCfg.font = propUpdate("font"); + cloudCfg.fontStyle = propUpdate("fontStyle"); + cloudCfg.fontWeight = propUpdate("fontWeight"); + cloudCfg.fontSize = () => ({ + on: () => ({ start: _.noop }), + }); + return cloudCfg; + }); + + jest.mock("popsicle", () => mockBuildLibs); + jest.mock("d3-cloud", () => mockD3Cloud); + jest.mock("chart.js", () => mockChartUtils); + jest.mock("chartjs-plugin-zoom", () => ({})); + jest.mock("chartjs-chart-box-and-violin-plot/build/Chart.BoxPlot.js", () => ({})); + }); + + afterAll(() => { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", originalOffsetHeight); + Object.defineProperty(HTMLElement.prototype, "offsetWidth", originalOffsetWidth); + }); + + test("Charts: rendering", done => { + const Charts = require("../../../popups/charts/Charts").ReactCharts; + const ChartsBody = require("../../../popups/charts/ChartsBody").default; + buildInnerHTML({ settings: "" }); + const result = mount(, { + attachTo: document.getElementById("content"), + }); + + setTimeout(() => { + result.update(); + const filters = result.find(Charts).find(Select); + filters + .first() + .instance() + .onChange({ value: "col4" }); + filters + .at(1) + .instance() + .onChange([{ value: "col1" }]); + updateChartType(result, ChartsBody, "scatter"); + result + .find(Charts) + .find("button") + .first() + .simulate("click"); + setTimeout(() => { + result.update(); + updateChartType(result, ChartsBody, "bar"); + result + .find(Charts) + .find("button") + .first() + .simulate("click"); + setTimeout(() => { + result.update(); + done(); + }, 400); + }, 400); + }, 600); + }); +}); diff --git a/static/__tests__/popups/window/Charts-test.jsx b/static/__tests__/popups/window/Charts-test.jsx index a7948f0ba..ad94e5546 100644 --- a/static/__tests__/popups/window/Charts-test.jsx +++ b/static/__tests__/popups/window/Charts-test.jsx @@ -1,3 +1,4 @@ +/* eslint max-statements: "off" */ import qs from "querystring"; import { mount } from "enzyme"; @@ -5,6 +6,8 @@ import _ from "lodash"; import React from "react"; import Select from "react-select"; +import { RemovableError } from "../../../RemovableError"; +import { Aggregations } from "../../../popups/charts/Aggregations"; import mockPopsicle from "../../MockPopsicle"; import * as t from "../../jest-assertions"; import { buildInnerHTML, withGlobalJquery } from "../../test-utils"; @@ -12,6 +15,16 @@ import { buildInnerHTML, withGlobalJquery } from "../../test-utils"; const originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"); const originalOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"); +function updateChartType(result, cmp, chartType) { + result + .find(cmp) + .find(Select) + .first() + .instance() + .onChange({ value: chartType }); + result.update(); +} + describe("Charts tests", () => { beforeAll(() => { Object.defineProperty(HTMLElement.prototype, "offsetHeight", { @@ -29,6 +42,9 @@ describe("Charts tests", () => { if (urlParams.x === "error" && urlParams.y === "error2") { return { data: {} }; } + if (_.startsWith(url, "/dtale/dtypes/9")) { + return { error: "error test" }; + } const { urlFetcher } = require("../../redux-test-utils").default; return urlFetcher(url); }) @@ -96,11 +112,23 @@ describe("Charts tests", () => { filters .at(1) .instance() - .onChange({ value: "col1" }); + .onChange([{ value: "col1" }]); filters .at(3) .instance() - .onChange({ value: "count", label: "Count" }); + .onChange({ value: "rolling", label: "Rolling" }); + result.update(); + result + .find(Aggregations) + .find("input") + .at(1) + .simulate("change", { target: { value: "10" } }); + result + .find(Aggregations) + .find(Select) + .last() + .instance() + .onChange({ value: "corr", label: "Correlation" }); result .find(Charts) .find("input.form-control") @@ -116,8 +144,10 @@ describe("Charts tests", () => { result.update(); t.ok(result.find(ChartsBody).instance().state.charts.length == 1, "should render charts"); t.ok( - result.find(Charts).instance().state.url, - "/dtale/chart-data/1?x=date&y=security_id&query=date%20%3D%3D%20\\'20181201\\'&agg=count", + _.endsWith( + result.find(Charts).instance().state.url, + "x=col4&y=col1&query=col4%20%3D%3D%20'20181201'&agg=rolling&rollingWin=10&rollingComp=corr" + ), "should update chart URL" ); result @@ -127,19 +157,14 @@ describe("Charts tests", () => { result.update(); t.deepEqual( result.find(ChartsBody).instance().state.charts[0].options.scales.xAxes[0], - { ticks: { max: 1545973200000, min: 1545109200000 } }, + { ticks: { max: 1545800400000, min: 1545109200000 } }, "should limit x-axis" ); - t.equal( - result - .find(ChartsBody) - .instance() - .state.charts[0].cfg.options.tooltips.callbacks.title([{ xLabel: 1545973200000 }]), - "2018-12-28", - "should correctly render dates in tooltip" - ); + const chartObj = result.find(ChartsBody).instance().state.charts[0]; + const tsTitle = chartObj.cfg.options.tooltips.callbacks.title([{ index: 0 }], chartObj.data); + t.ok(tsTitle === "2018-12-18", "should return timestamp in tooltip"); result - .find(Charts) + .find(ChartsBody) .instance() .resetZoom(); result.update(); @@ -147,27 +172,15 @@ describe("Charts tests", () => { result.find(ChartsBody).instance().state.charts[0].options.scales.xAxes[0].length == 0, "should clear limited x-axis" ); - filters = result.find(Charts).find(Select); - filters - .last() - .instance() - .onChange({ value: "bar" }); - result.update(); - filters - .last() - .instance() - .onChange({ value: "wordcloud" }); - result.update(); - filters - .last() - .instance() - .onChange({ value: "stacked" }); - result.update(); - filters - .last() - .instance() - .onChange({ value: "pie" }); - result.update(); + updateChartType(result, ChartsBody, "bar"); + t.ok(result.find(ChartsBody).instance().state.charts[0].cfg.type === "bar"); + updateChartType(result, ChartsBody, "wordcloud"); + updateChartType(result, ChartsBody, "stacked"); + t.ok(result.find(ChartsBody).instance().state.charts[0].cfg.options.scales.xAxes[0].stacked); + updateChartType(result, ChartsBody, "scatter"); + t.ok(result.find(ChartsBody).instance().state.charts[0].cfg.type === "scatter"); + updateChartType(result, ChartsBody, "pie"); + t.ok(result.find(ChartsBody).instance().state.charts[0].cfg.type === "pie"); filters = result.find(Charts).find(Select); filters .at(3) @@ -184,30 +197,18 @@ describe("Charts tests", () => { .simulate("click"); setTimeout(() => { result.update(); - filters - .last() - .instance() - .onChange({ value: "line" }); - result.update(); + updateChartType(result, ChartsBody, "line"); let chartObj = result.find(ChartsBody).instance().state.charts[0]; t.equal( chartObj.cfg.options.tooltips.callbacks.label( { xLabel: 1545973200000, yLabel: 1.123456, datasetIndex: 0 }, chartObj.data ), - "val1: 1.1235", + "val1 - col1: 1.1235", "should render tooltip label" ); - filters - .last() - .instance() - .onChange({ value: "wordcloud" }); - result.update(); - filters - .last() - .instance() - .onChange({ value: "line" }); - result.update(); + updateChartType(result, ChartsBody, "wordcloud"); + updateChartType(result, ChartsBody, "line"); result .find(Charts) .find("input") @@ -225,11 +226,7 @@ describe("Charts tests", () => { "1.1235", "should render tooltip label" ); - filters - .last() - .instance() - .onChange({ value: "wordcloud" }); - result.update(); + updateChartType(result, ChartsBody, "wordcloud"); const wc = result.find(ReactWordcloud).first(); t.ok(wc.props().callbacks.getWordTooltip({ fullText: "test", value: 5 }), "test (5)", "should show tooltip"); const cb = result.find(ChartsBody).instance(); @@ -259,7 +256,7 @@ describe("Charts tests", () => { setTimeout(() => { result.update(); - let filters = result.find(Charts).find(Select); + const filters = result.find(Charts).find(Select); filters .first() .instance() @@ -267,7 +264,7 @@ describe("Charts tests", () => { filters .at(1) .instance() - .onChange({ value: "error2" }); + .onChange([{ value: "error2" }]); result .find(Charts) .find("button") @@ -275,15 +272,24 @@ describe("Charts tests", () => { .simulate("click"); setTimeout(() => { result.update(); - filters = result.find(Charts).find(Select); - filters - .last() - .instance() - .onChange({ value: "bar" }); - result.update(); + updateChartType(result, ChartsBody, "bar"); t.ok(result.find(ChartsBody).instance().state.charts === null, "should not render chart"); done(); }, 400); }, 400); }); + + test("Charts: rendering empty data", done => { + const Charts = require("../../../popups/charts/Charts").ReactCharts; + buildInnerHTML({ settings: "" }); + const result = mount(, { + attachTo: document.getElementById("content"), + }); + + setTimeout(() => { + result.update(); + t.equal(result.find(RemovableError).text(), "error test"); + done(); + }, 400); + }); }); diff --git a/static/__tests__/redux-test-utils.jsx b/static/__tests__/redux-test-utils.jsx index d97d23a1f..b8f7ff1bb 100644 --- a/static/__tests__/redux-test-utils.jsx +++ b/static/__tests__/redux-test-utils.jsx @@ -121,7 +121,7 @@ const PROCESSES = [ columns: 3, }, ]; - +// eslint-disable-next-line max-statements function urlFetcher(url) { const urlParams = qs.parse(url.split("?")[1]); const query = urlParams.query; @@ -140,8 +140,18 @@ function urlFetcher(url) { return scatterData; } else if (url.startsWith("/dtale/chart-data")) { if (urlParams.group) { + if (_.includes(urlParams.y, ",")) { + return _.assignIn({}, groupedChartsData, { + data: _.mapValues(groupedChartsData.data, d => _.assignIn(d, { col2: d.col1 })), + }); + } return groupedChartsData; } + if (_.includes(urlParams.y, ",")) { + return _.assignIn({}, chartsData, { + data: _.mapValues(chartsData.data, d => _.assignIn(d, { col2: d.col1 })), + }); + } return chartsData; } else if (url.startsWith("/dtale/update-settings")) { return { success: true }; diff --git a/static/__tests__/popups/scatterChartUtils-test.jsx b/static/__tests__/scatterChartUtils-test.jsx similarity index 68% rename from static/__tests__/popups/scatterChartUtils-test.jsx rename to static/__tests__/scatterChartUtils-test.jsx index 0ab7c6174..3d67969bc 100644 --- a/static/__tests__/popups/scatterChartUtils-test.jsx +++ b/static/__tests__/scatterChartUtils-test.jsx @@ -1,8 +1,7 @@ import _ from "lodash"; -import corrUtils from "../../popups/correlations/correlationsUtils"; -import { formatScatterPoints } from "../../popups/scatterChartUtils"; -import * as t from "../jest-assertions"; +import { basePointFormatter, formatScatterPoints, getScatterMax, getScatterMin } from "../scatterChartUtils"; +import * as t from "./jest-assertions"; describe("scatterChartUtils tests", () => { test("scatterChartUtils: testing filtering/highlighting logic", done => { @@ -20,11 +19,16 @@ describe("scatterChartUtils tests", () => { ]; const scatterData = formatScatterPoints( data, - corrUtils.pointFormatter("col", "col2"), + basePointFormatter("col", "col2"), _.matches({ index: 0 }), _.matches({ index: 9 }) ); t.deepEqual(scatterData.pointRadius, [3, 3, 3, 3, 3, 3, 3, 3, 0, 5], "should set correct radii"); + + t.equal(getScatterMin(data, "col"), -2.5); + t.equal(getScatterMax(data, "col"), 10.5); + t.equal(getScatterMax([3, 4, 2, 1]), 5.5); + t.equal(getScatterMin([3, 4, 2, 1]), -0.5); done(); }); }); diff --git a/static/__tests__/toggleUtils-test.jsx b/static/__tests__/toggleUtils-test.jsx new file mode 100644 index 000000000..7e3c16655 --- /dev/null +++ b/static/__tests__/toggleUtils-test.jsx @@ -0,0 +1,15 @@ +import { buildButton } from "../toggleUtils"; +import * as t from "./jest-assertions"; + +describe("toggleUtils tests", () => { + test("toggleUtils: testing buildButton", done => { + let props = buildButton(true, () => "active"); + t.equal(props.className, "btn btn-primary active"); + t.equal(props.onClick(), undefined); + props = buildButton(false, () => "active", true); + t.equal(props.className, "btn btn-primary "); + t.equal(props.onClick(), "active"); + t.ok(props.disabled); + done(); + }); +}); diff --git a/static/chartUtils.jsx b/static/chartUtils.jsx index 2f5475fd0..b12a57f11 100644 --- a/static/chartUtils.jsx +++ b/static/chartUtils.jsx @@ -1,3 +1,4 @@ +/* eslint max-lines: "off" */ import Chart from "chart.js"; import "chartjs-chart-box-and-violin-plot/build/Chart.BoxPlot.js"; import "chartjs-plugin-zoom"; @@ -6,6 +7,7 @@ import _ from "lodash"; import moment from "moment"; import { isDateCol } from "./dtale/gridUtils"; +import { formatScatterPoints, getScatterMax, getScatterMin } from "./scatterChartUtils"; // needed to add these parameters because Chart.Zoom.js causes Chart.js to look for them const DEFAULT_OPTIONS = { pan: { enabled: false }, zoom: { enabled: false } }; @@ -66,7 +68,7 @@ function buildRGBA(colorScale) { ")"; } -const gradientLinePlugin = (colorScale, minY = null, maxY = null) => ({ +const gradientLinePlugin = (colorScale, yAxisID, minY = null, maxY = null) => ({ afterLayout: chartInstance => { const rgbaBuilder = buildRGBA(colorScale); // The context, needed for the creation of the linear gradient. @@ -82,7 +84,7 @@ const gradientLinePlugin = (colorScale, minY = null, maxY = null) => ({ finalMaxY = _.isNull(finalMaxY) ? _.last(data).y : finalMaxY; } // Calculate Y pixels for min and max values. - const yAxis = chartInstance.scales["y-axis-0"]; + const yAxis = chartInstance.scales[yAxisID || "y-axis-0"]; const minValueYPixel = yAxis.getPixelForValue(minY); const maxValueYPixel = yAxis.getPixelForValue(maxY); // Create the gradient. @@ -105,28 +107,98 @@ const gradientLinePlugin = (colorScale, minY = null, maxY = null) => ({ const COLOR_SCALE = chroma.scale(["orange", "yellow", "green", "lightblue", "darkblue"]); -function updateCfgForDates(cfg, { columns, x }) { - if (isDateCol(_.find(columns, { name: x }).dtype)) { - const units = _.size(cfg.data.labels) > 150 ? "month" : "day"; +function timestampLabel(ts) { + const startOfDay = moment(new Date(ts)) + .startOf("day") + .valueOf(); + if (startOfDay == ts) { + return moment(new Date(ts)).format("YYYY-MM-DD"); + } + return moment(new Date(ts)).format("YYYY-MM-DD h:mm:ss a"); +} + +function getTimeCfg(data, minTS, maxTS) { + const diffInDays = (maxTS - minTS) / (1000 * 3600 * 24); + let units = "year"; + let stepSize = 1; + let unitsFmt = "YYYYMMDD"; + if (diffInDays < 10 && diffInDays < _.size(data) - 2) { + units = "hour"; + stepSize = 4; + unitsFmt += " hA"; + } else if (diffInDays < 50) { + units = "day"; + } else if (diffInDays < 365 / 2) { + units = "week"; + } else if (diffInDays < 365 * 4) { + units = "month"; + } else if (diffInDays < 365 * 10) { + units = "quarter"; + } + return { units, stepSize, unitsFmt }; +} + +function updateCfgForDates(cfg, { columns, x, y }, { min, max }) { + if (isDateCol(_.get(_.find(columns || {}, { name: x }), "dtype", ""))) { + const minTS = min[x]; + const maxTS = max[x]; + const { units, stepSize, unitsFmt } = getTimeCfg(cfg.data.labels || _.map(cfg.data.datasets[0], "x"), minTS, maxTS); cfg.options.scales.xAxes = [ { type: "time", distribution: "series", time: { unit: units, + stepSize, displayFormats: { - [units]: "YYYYMMDD", + [units]: unitsFmt, }, }, ticks: { - min: _.get(cfg.data.labels, "0"), - max: _.get(cfg.data.labels, cfg.data.labels.length - 1), + min: minTS, + max: maxTS, }, }, ]; - cfg.options.tooltips.callbacks.title = (tooltipItems, _data) => - moment(new Date(tooltipItems[0].xLabel)).format("YYYY-MM-DD"); + cfg.options.tooltips.callbacks.title = (tooltipItems, data) => + timestampLabel(_.get(data, ["labels", tooltipItems[0].index])); } + _.forEach(y, (yProp, idx) => { + if (isDateCol(_.get(_.find(columns || [], { name: yProp }), "dtype", ""))) { + let series = _.get(cfg, ["data", "datasets", idx, "data"], []); + if (_.isObject(_.head(series))) { + series = _.map(series, "y"); + } + const minTS = min[yProp]; + const maxTS = max[yProp]; + const { units, stepSize, unitsFmt } = getTimeCfg(series, minTS, maxTS); + cfg.options.scales.yAxes[idx] = _.assignIn(cfg.options.scales.yAxes[idx], { + type: "time", + distribution: "series", + time: { + unit: units, + stepSize, + displayFormats: { + [units]: unitsFmt, + }, + }, + ticks: { + min: minTS, + max: maxTS, + }, + }); + /*cfg.options.tooltips.callbacks.label = (tooltipItem, data) => { + const value = timestampLabel(tooltipItem.yLabel); + if (_.size(data.datasets) * _.size(y) > 1) { + const label = data.datasets[tooltipItem.datasetIndex].label || ""; + if (label) { + return `${label}: ${value}`; + } + } + return value; + }*/ + } + }); } function updateLegend(cfg) { @@ -135,26 +207,45 @@ function updateLegend(cfg) { } } -function buildSeries(label, { y }, _idx) { +function buildSeries(label, data, _idx, yProp) { const ptCfg = { fill: false, lineTension: 0.1, pointRadius: 0, pointHoverRadius: 5, pointHitRadius: 5, - data: y, + data: data[yProp], + yAxisID: `y-${yProp}`, }; + const labels = []; if (label !== "all") { - ptCfg.label = label; + labels.push(label); + } + if (yProp !== "y") { + labels.push(yProp); + } + if (_.size(labels) > 0) { + ptCfg.label = _.join(labels, " - "); } return ptCfg; } -function createBaseCfg({ data }, { x, y, additionalOptions }, seriesFormatter = buildSeries) { +// eslint-disable-next-line no-unused-vars +function buildTicks(prop, { min, max }, pad = false) { + const range = { min: min[prop], max: max[prop] }; + if (pad) { + const padFactor = (range.max - range.min) * 0.01; + return { min: range.min - padFactor, max: range.max + padFactor }; + } + return range; +} + +function createBaseCfg({ data, min, max }, { x, y, additionalOptions }, seriesFormatter = buildSeries) { + let seriesIdx = 0; const cfg = { data: { labels: _.get(_.values(data), "0.x"), - datasets: _.map(_.toPairs(data), ([k, v], i) => seriesFormatter(k, v, i)), + datasets: _.flatMap(_.toPairs(data), ([k, v]) => _.map(y, yProp => seriesFormatter(k, v, seriesIdx++, yProp))), }, options: _.assignIn( { @@ -167,7 +258,7 @@ function createBaseCfg({ data }, { x, y, additionalOptions }, seriesFormatter = callbacks: { label: (tooltipItem, chartData) => { const value = _.round(tooltipItem.yLabel, 4); - if (_.size(data) > 1) { + if (_.size(data) * _.size(y) > 1) { const label = chartData.datasets[tooltipItem.datasetIndex].label || ""; if (label) { return `${label}: ${value}`; @@ -190,14 +281,15 @@ function createBaseCfg({ data }, { x, y, additionalOptions }, seriesFormatter = }, }, ], - yAxes: [ - { - scaleLabel: { - display: true, - labelString: y, - }, + yAxes: _.map(y, (yProp, idx) => ({ + scaleLabel: { + display: true, + labelString: yProp, }, - ], + ticks: buildTicks(yProp, { min, max }), + id: `y-${yProp}`, + position: idx % 2 == 0 ? "left" : "right", + })), }, }, additionalOptions @@ -207,43 +299,43 @@ function createBaseCfg({ data }, { x, y, additionalOptions }, seriesFormatter = return cfg; } -function createLineCfg({ data }, { columns, x, y, additionalOptions, configHandler }) { - const seriesCt = _.size(data); +function createLineCfg({ data, min, max }, { columns, x, y, additionalOptions, configHandler }) { + const seriesCt = _.size(data) * _.size(y); const colors = COLOR_SCALE.domain([0, seriesCt]); - const seriesFormatter = (k, v, i) => { - const ptCfg = buildSeries(k, v, i); + const seriesFormatter = (k, v, i, yProp) => { + const ptCfg = buildSeries(k, v, i, yProp); _.forEach(COLOR_PROPS, cp => (ptCfg[cp] = seriesCt == 1 ? "rgb(42, 145, 209)" : colors(i).hex())); return ptCfg; }; - const cfg = createBaseCfg({ data }, { columns, x, y, additionalOptions }, seriesFormatter); + const cfg = createBaseCfg({ data, min, max }, { columns, x, y, additionalOptions }, seriesFormatter); cfg.type = "line"; - updateCfgForDates(cfg, { columns, x }); + updateCfgForDates(cfg, { columns, x, y }, { min, max }); return configHandler(cfg); } -function createBarCfg({ data }, { columns, x, y, additionalOptions, configHandler }) { - const cfg = createLineCfg({ data }, { columns, x, y, additionalOptions, configHandler }); +function createBarCfg({ data, min, max }, { columns, x, y, additionalOptions, configHandler }) { + const cfg = createLineCfg({ data, min, max }, { columns, x, y, additionalOptions, configHandler }); cfg.type = "bar"; return cfg; } -function createStackedCfg({ data }, { columns, x, y, additionalOptions, configHandler }) { - const cfg = createLineCfg({ data }, { columns, x, y, additionalOptions, configHandler }); +function createStackedCfg({ data, min, max }, { columns, x, y, additionalOptions, configHandler }) { + const cfg = createLineCfg({ data, min, max }, { columns, x, y, additionalOptions, configHandler }); cfg.type = "bar"; cfg.options.scales.xAxes[0].stacked = true; - cfg.options.scales.yAxes[0].stacked = true; + _.forEach(cfg.options.scales.yAxes, axisCfg => (axisCfg.stacked = true)); return cfg; } -function createPieCfg({ data }, { columns, x, y, additionalOptions, configHandler }) { +function createPieCfg({ data, min, max }, { columns, x, y, additionalOptions, configHandler }) { const seriesCt = _.get(_.values(data), "0.x.length"); const colors = COLOR_SCALE.domain([0, seriesCt]); - const seriesFormatter = (k, v, i) => { - const ptCfg = buildSeries(k, v, i); + const seriesFormatter = (k, v, i, yProp) => { + const ptCfg = buildSeries(k, v, i, yProp); ptCfg.backgroundColor = _.map(v.y, (_p, i) => (seriesCt == 1 ? "rgb(42, 145, 209)" : colors(i).hex())); return ptCfg; }; - const cfg = createBaseCfg({ data }, { columns, x, y, additionalOptions, configHandler }, seriesFormatter); + const cfg = createBaseCfg({ data, min, max }, { columns, x, y, additionalOptions, configHandler }, seriesFormatter); cfg.type = "pie"; delete cfg.options.scales; delete cfg.options.tooltips; @@ -253,6 +345,66 @@ function createPieCfg({ data }, { columns, x, y, additionalOptions, configHandle return configHandler(cfg); } +const SCATTER_BUILDER = (data, prop) => + _.map(_.zip(data.all.x, data.all[prop]), ([xVal, yVal]) => ({ + x: xVal, + y: yVal, + })); + +function createScatterCfg( + { data, min, max }, + { columns, x, y, additionalOptions, configHandler }, + builder = SCATTER_BUILDER +) { + const yProp = _.head(y); + const chartData = builder(data, yProp); + const scatterData = formatScatterPoints(chartData); + const cfg = { + type: "scatter", + data: { + datasets: [_.assign({ xLabels: [x], yLabels: [yProp] }, scatterData)], + }, + options: _.assignIn( + { + tooltips: { + callbacks: { + label: (tooltipItem, chartData) => { + const dataset = chartData.datasets[tooltipItem.datasetIndex]; + const pointData = dataset.data[tooltipItem.index]; + return [ + `${dataset.xLabels[0]}: ${_.round(pointData.x, 4)}`, + `${dataset.yLabels[0]}: ${_.round(pointData.y, 4)}`, + ]; + }, + }, + }, + scales: { + xAxes: [ + { + scaleLabel: { display: true, labelString: x }, + }, + ], + yAxes: [ + { + ticks: buildTicks(yProp, { min, max }), + scaleLabel: { display: true, labelString: yProp }, + }, + ], + }, + legend: { display: false }, + pan: { enabled: true, mode: "x" }, + zoom: { enabled: true, mode: "x" }, + maintainAspectRatio: false, + responsive: false, + showLines: false, + }, + additionalOptions + ), + }; + updateCfgForDates(cfg, { columns, x, y }, { min, max }); + return _.isUndefined(configHandler) ? cfg : configHandler(cfg); +} + export default { createChart, chartWrapper, @@ -262,5 +414,11 @@ export default { createLineCfg, createBarCfg, createStackedCfg, + createScatterCfg, createPieCfg, + timestampLabel, + formatScatterPoints, + getScatterMax, + getScatterMin, + buildTicks, }; diff --git a/static/dtale/Header.jsx b/static/dtale/Header.jsx index 54a8f6ca6..13cd803b6 100644 --- a/static/dtale/Header.jsx +++ b/static/dtale/Header.jsx @@ -8,8 +8,8 @@ import menuUtils from "../menuUtils"; import * as gu from "./gridUtils"; const SORT_CHARS = { - DESC: String.fromCharCode("9650"), - ASC: String.fromCharCode("9660"), + ASC: String.fromCharCode("9650"), + DESC: String.fromCharCode("9660"), }; class ReactHeader extends React.Component { diff --git a/static/dtale/iframe/ColumnMenu.jsx b/static/dtale/iframe/ColumnMenu.jsx index ec8fab5da..ea8c8e529 100644 --- a/static/dtale/iframe/ColumnMenu.jsx +++ b/static/dtale/iframe/ColumnMenu.jsx @@ -32,7 +32,7 @@ class ReactColumnMenu extends React.Component { col: selectedCol, }); const openDescribe = () => { - window.open(describeUrl, "_blank", "titlebar=1,location=1,status=1,width=500,height=450"); + window.open(describeUrl, "_blank", "titlebar=1,location=1,status=1,width=1100,height=450"); }; const histogramUrl = buildURLString(`/dtale/popup/histogram/${dataId}`, { col: selectedCol, diff --git a/static/popups/Correlations.jsx b/static/popups/Correlations.jsx index f8815bdd8..2a02326f9 100644 --- a/static/popups/Correlations.jsx +++ b/static/popups/Correlations.jsx @@ -11,6 +11,7 @@ import { closeChart } from "../actions/charts"; import { buildURL } from "../actions/url-utils"; import chartUtils from "../chartUtils"; import { fetchJson } from "../fetcher"; +import { toggleBouncer } from "../toggleUtils"; import ChartsBody from "./charts/ChartsBody"; import CorrelationScatterStats from "./correlations/CorrelationScatterStats"; import CorrelationsGrid from "./correlations/CorrelationsGrid"; @@ -134,9 +135,9 @@ class ReactCorrelations extends React.Component { if (this.state.scatterUrl === scatterUrl) { return; } - corrUtils.toggleBouncer(); + toggleBouncer(["scatter-bouncer", "rawScatterChart"]); fetchJson(scatterUrl, fetchedChartData => { - corrUtils.toggleBouncer(); + toggleBouncer(["scatter-bouncer", "rawScatterChart"]); const newState = { selectedCols, stats: fetchedChartData.stats, @@ -148,11 +149,11 @@ class ReactCorrelations extends React.Component { newState.scatterError = ; } const builder = ctx => { - if (!_.get(fetchedChartData, "data", []).length) { + if (!_.get(fetchedChartData, "data.all.x", []).length) { return null; } - const { data, x, y } = fetchedChartData; - return corrUtils.createScatter(ctx, data, x, y, this.props.chartData.title, this.viewScatterRow); + const { x, y } = fetchedChartData; + return corrUtils.createScatter(ctx, fetchedChartData, x, y, this.viewScatterRow); }; newState.chart = chartUtils.chartWrapper("rawScatterChart", this.state.chart, builder); this.setState(newState); @@ -196,11 +197,11 @@ class ReactCorrelations extends React.Component { visible={true} url={tsUrl} columns={[ - { name: "date", dtype: "datetime[ns]" }, + { name: "x", dtype: "datetime[ns]" }, { name: "corr", dtype: "float64" }, ]} - x="date" - y="corr" + x={{ value: "x" }} + y={[{ value: "corr" }]} configHandler={config => { config.options.scales.yAxes = [ { @@ -209,16 +210,18 @@ class ReactCorrelations extends React.Component { data.ticks[0] = null; data.ticks[data.ticks.length - 1] = null; }, + id: "y-corr", }, ]; if (!this.state.rolling) { config.options.onClick = this.viewScatter; } config.options.legend = { display: false }; - config.plugins = [chartUtils.gradientLinePlugin(corrUtils.colorScale, -1, 1)]; + config.plugins = [chartUtils.gradientLinePlugin(corrUtils.colorScale, "y-corr", -1, 1)]; return config; }} height={300} + showControls={false} /> diff --git a/static/popups/Histogram.jsx b/static/popups/Histogram.jsx index 51adbd68e..56732fc1f 100644 --- a/static/popups/Histogram.jsx +++ b/static/popups/Histogram.jsx @@ -51,7 +51,7 @@ function createHistogram(ctx, fetchedData, col) { class ReactHistogram extends React.Component { constructor(props) { super(props); - this.state = { chart: null, bins: 20 }; + this.state = { chart: null, bins: "20" }; this.buildHistogramFilters = this.buildHistogramFilters.bind(this); this.buildHistogram = this.buildHistogram.bind(this); } @@ -76,7 +76,9 @@ class ReactHistogram extends React.Component { componentDidUpdate(_prevProps, prevState) { if (this.state.bins !== prevState.bins) { - this.buildHistogram(); + if (this.state.bins && parseInt(this.state.bins)) { + this.buildHistogram(); + } } } @@ -85,17 +87,16 @@ class ReactHistogram extends React.Component { } buildHistogramFilters() { - const binChange = e => this.setState({ bins: _.parseInt(e.target.value) }); return (
-
- +
+ this.setState({ bins: e.target.value })} + />
); diff --git a/static/popups/charts/Aggregations.jsx b/static/popups/charts/Aggregations.jsx new file mode 100644 index 000000000..c4a3c7d2c --- /dev/null +++ b/static/popups/charts/Aggregations.jsx @@ -0,0 +1,143 @@ +import _ from "lodash"; +import PropTypes from "prop-types"; +import React from "react"; +import Select, { createFilter } from "react-select"; + +import { isDateCol } from "../../dtale/gridUtils"; + +const AGGREGATION_OPTS = [ + { value: "count", label: "Count" }, + { value: "nunique", label: "Unique Count" }, + { value: "sum", label: "Sum" }, + { value: "mean", label: "Mean" }, + { value: "rolling", label: "Rolling" }, + { value: "first", label: "First" }, + { value: "last", label: "Last" }, + { value: "median", label: "Median" }, + { value: "min", label: "Minimum" }, + { value: "max", label: "Maximum" }, + { value: "std", label: "Standard Deviation" }, + { value: "var", label: "Variance" }, + { value: "mad", label: "Mean Absolute Deviation" }, + { value: "prod", label: "Product of All Items" }, +]; + +function getAggregations({ columns, x, group }) { + if (isDateCol(_.get(_.find(columns, { name: _.get(x, "value") }), "dtype", "")) && _.isEmpty(group)) { + return AGGREGATION_OPTS; + } + return _.reject(AGGREGATION_OPTS, { value: "rolling" }); +} + +const ROLLING_COMPS = [ + { value: "corr", label: "Correlation" }, + { value: "count", label: "Count" }, + { value: "cov", label: "Covariance" }, + { value: "kurt", label: "Kurtosis" }, + { value: "max", label: "Maximum" }, + { value: "mean", label: "Mean" }, + { value: "median", label: "median" }, + { value: "min", label: "Minimum" }, + { value: "skew", label: "Skew" }, + { value: "std", label: "Standard Deviation" }, + { value: "sum", label: "Sum" }, + { value: "var", label: "Variance" }, +]; + +class Aggregations extends React.Component { + constructor(props) { + super(props); + this.state = { + aggregation: props.aggregation ? _.find(AGGREGATION_OPTS, { value: props.aggregation }) : null, + rollingComputation: null, + rollingWindow: "4", + }; + this.update = this.update.bind(this); + this.renderRolling = this.renderRolling.bind(this); + } + + update(val, upperVal) { + this.setState(val, () => this.props.propagateState(upperVal)); + } + + renderRolling() { + if (_.get(this.state, "aggregation.value") === "rolling") { + const { rollingWindow, rollingComputation } = this.state; + return [ + + Window: + , +
+ + this.update( + { rollingWindow: _.get(e, "target.value", "") }, + { rollingWindow: _.get(e, "target.value", "") } + ) + } + /> +
, + + Computation: + , +
+
+ this.update({ aggregation }, { aggregation: _.get(aggregation, "value") })} + isClearable + filterOption={createFilter({ ignoreAccents: false })} // required for performance reasons! + /> +
+
, + this.renderRolling(), + ]; + } +} +Aggregations.displayName = "Aggregations"; +Aggregations.propTypes = { + propagateState: PropTypes.func, + columns: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/no-unused-prop-types + x: PropTypes.object, // eslint-disable-line react/no-unused-prop-types + group: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/no-unused-prop-types + aggregation: PropTypes.string, // eslint-disable-line react/no-unused-prop-types +}; + +export { Aggregations, AGGREGATION_OPTS, ROLLING_COMPS }; diff --git a/static/popups/charts/AxisEditor.css b/static/popups/charts/AxisEditor.css new file mode 100644 index 000000000..47334d87e --- /dev/null +++ b/static/popups/charts/AxisEditor.css @@ -0,0 +1,52 @@ +.axis-toggle__dropdown { + position: absolute; + top: 100%; + min-width: 15em; + width: 100%; + color: #404040; + font-weight: 400; + z-index: 10; + margin-top: -1px; + background-color: white; + border-width: 1px 1px 0 1px; + border-style: solid; + border-color: #a7b3b7; + border-top-color: #d3d9db; + border-radius: 0 0 0.25rem 0.25rem; +} + +.axis-toggle__dropdown ul { + margin: 0; + padding: 0; + list-style: none; +} + +.axis-toggle__dropdown li { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + width: 100%; +} + +.axis-toggle__dropdown li:last-child { + border-bottom: solid 1px #a7b3b7; +} + +.axis-toggle__dropdown li span { + padding: 0.3em; +} + +.axis-toggle__dropdown li span.toggler-action { + border: none; + margin-top: -0.1em; +} + +.axis-toggle__dropdown li span.toggler-action button i { + vertical-align: text-bottom; +} + +.axis-input { + text-align: center; + width: 3em !important; + padding: 0.3rem 0.3rem !important; +} diff --git a/static/popups/charts/AxisEditor.jsx b/static/popups/charts/AxisEditor.jsx new file mode 100644 index 000000000..5ce25feae --- /dev/null +++ b/static/popups/charts/AxisEditor.jsx @@ -0,0 +1,126 @@ +import _ from "lodash"; +import PropTypes from "prop-types"; +import React from "react"; + +import menuUtils from "../../menuUtils"; + +function buildState({ y, data }) { + const state = {}; + _.forEach(y, ({ value }) => { + state[`${value}-min`] = _.get(data, ["min", value], ""); + state[`${value}-max`] = _.get(data, ["max", value], ""); + }); + return state; +} + +require("./AxisEditor.css"); + +class AxisEditor extends React.Component { + constructor(props) { + super(props); + this.state = _.assignIn({ open: false }, buildState(props)); + this.closeMenu = this.closeMenu.bind(this); + } + + shouldComponentUpdate(newProps, newState) { + return !_.isEqual(this.props.data, newProps.data) || !_.isEqual(this.state, newState); + } + + componentDidUpdate(prevProps) { + if (!_.isEqual(this.props.data, prevProps.data)) { + this.setState(buildState(this.props)); + } + } + + closeMenu() { + const settings = { + min: _.assign({}, this.props.data.min), + max: _.assign({}, this.props.data.max), + }; + const errors = []; + _.forEach(this.props.y, ({ value }) => { + _.forEach(["min", "max"], prop => { + const curr = this.state[`${value}-${prop}`]; + if (curr !== "" && !_.isNaN(parseFloat(curr))) { + settings[prop][value] = parseFloat(curr); + } else { + errors.push(`${value} has invalid ${prop}!`); + } + }); + }); + _.forEach(this.props.y, ({ value }) => { + if (settings.min[value] > settings.max[value]) { + errors.push(`${value} must have a min < max!`); + } + }); + if (_.size(errors)) { + this.setState({ errors }); + } else { + this.setState({ open: false }, () => this.props.updateAxis(settings)); + } + } + + render() { + if (_.isEmpty(this.props.data)) { + return null; + } + const { min, max } = this.props.data; + const { y } = this.props; + const axisMarkup = _.map(y, ({ value }, idx) => { + const minProp = `${value}-min`; + const maxProp = `${value}-max`; + return ( +
  • + {value} + Min: + + this.setState({ [minProp]: e.target.value })} + /> + + Max: + + this.setState({ [maxProp]: e.target.value })} + /> + +
  • + ); + }); + const menuHandler = menuUtils.openMenu("axisEditor", () => this.setState({ open: true }), this.closeMenu); + return ( +
    +
    + Axis Ranges +
    + + {_.truncate( + _.join( + _.map(y, ({ value }) => `${value} (${min[value]},${max[value]})`), + ", " + ) + )} + + +
    +
    +
    + ); + } +} +AxisEditor.displayName = "AxisEditor"; +AxisEditor.propTypes = { + data: PropTypes.object, + y: PropTypes.arrayOf(PropTypes.object), + updateAxis: PropTypes.func, +}; + +export default AxisEditor; diff --git a/static/popups/charts/ChartLabel.jsx b/static/popups/charts/ChartLabel.jsx index 5ffc8b1e2..6af4aab53 100644 --- a/static/popups/charts/ChartLabel.jsx +++ b/static/popups/charts/ChartLabel.jsx @@ -2,27 +2,54 @@ import _ from "lodash"; import PropTypes from "prop-types"; import React from "react"; +import { AGGREGATION_OPTS, ROLLING_COMPS } from "./Aggregations"; + +function buildLabel({ x, y, group, aggregation, rollingWindow, rollingComputation }) { + const yLabel = _.join(_.map(y, "value"), ", "); + let labelStr = yLabel; + if (aggregation) { + const aggLabel = _.find(AGGREGATION_OPTS, { value: aggregation }).label; + if (aggregation === "rolling") { + const compLabel = _.find(ROLLING_COMPS, { value: rollingComputation }).label; + labelStr = `${aggLabel} ${compLabel} (window: ${rollingWindow}) of ${yLabel}`; + } else { + labelStr = `${aggLabel} of ${yLabel}`; + } + } + labelStr = `${labelStr} by ${_.get(x, "value")}`; + if (!_.isEmpty(group)) { + labelStr = `${labelStr} grouped by ${_.join(_.map(group, "value"), ", ")}`; + } + return labelStr; +} + class ChartLabel extends React.Component { - render() { - const { x, y, group, aggregation } = this.props; - let labelStr = aggregation ? `${aggregation.label} of ${y.value}` : y.value; - labelStr = `${labelStr} by ${x.value}`; - if (!_.isEmpty(group)) { - labelStr = `${labelStr} grouped by ${_.join(_.map(group, "value"), ", ")}`; + constructor(props) { + super(props); + this.state = { label: buildLabel(this.props) }; + } + + componentDidUpdate(prevProps) { + if (this.props.url !== prevProps.url) { + this.setState({ label: buildLabel(this.props) }); } + } + + render() { return (
    - {labelStr} + {this.state.label}
    ); } } ChartLabel.displayName = "ChartLabel"; ChartLabel.propTypes = { - x: PropTypes.object, - y: PropTypes.object, - group: PropTypes.arrayOf(PropTypes.object), - aggregation: PropTypes.object, + url: PropTypes.string, + x: PropTypes.object, // eslint-disable-line react/no-unused-prop-types + y: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/no-unused-prop-types + group: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/no-unused-prop-types + aggregation: PropTypes.string, // eslint-disable-line react/no-unused-prop-types }; export default ChartLabel; diff --git a/static/popups/charts/Charts.css b/static/popups/charts/Charts.css index 30bbffa33..750524bde 100644 --- a/static/popups/charts/Charts.css +++ b/static/popups/charts/Charts.css @@ -4,8 +4,6 @@ div.charts-body input[type='checkbox'] { div.charts-body { padding: 1em; } -/*.charts-filters .Select__control, -.charts-filters .Select__value-container, -.charts-filters .Select__placeholder { - height: calc(1.51562rem + 2px); -}*/ +div.charts-filters > span:first-child { + width: 5.5em; +} diff --git a/static/popups/charts/Charts.jsx b/static/popups/charts/Charts.jsx index f432fb017..b37518263 100644 --- a/static/popups/charts/Charts.jsx +++ b/static/popups/charts/Charts.jsx @@ -1,59 +1,55 @@ import _ from "lodash"; -import moment from "moment"; import PropTypes from "prop-types"; import React from "react"; import { connect } from "react-redux"; import Select, { createFilter } from "react-select"; -import ConditionalRender from "../../ConditionalRender"; -import { JSAnchor } from "../../JSAnchor"; import { RemovableError } from "../../RemovableError"; import { buildURLString } from "../../actions/url-utils"; -import { isDateCol } from "../../dtale/gridUtils"; import { fetchJson } from "../../fetcher"; -import ChartLabel from "./ChartLabel"; +import { Aggregations } from "./Aggregations"; import ChartsBody from "./ChartsBody"; -const AGGREGATIONS = [ - { value: "count", label: "Count" }, - { value: "first", label: "First" }, - { value: "last", label: "Last" }, - { value: "mean", label: "Mean" }, - { value: "median", label: "Median" }, - { value: "min", label: "Minimum" }, - { value: "max", label: "Maximum" }, - { value: "std", label: "Standard Deviation" }, - { value: "var", label: "Variance" }, - { value: "mad", label: "Mean Absolute Deviation" }, - { value: "prod", label: "Product of All Items" }, - { value: "sum", label: "Sum" }, -]; -const CHART_TYPES = ["line", "bar", "stacked", "pie", "wordcloud"]; - -function generateChartState({ group, x, y, query, aggregation }, { dataId }) { +function generateChartState(state, { dataId }) { + const { group, x, y, query, aggregation, rollingWindow, rollingComputation } = state; if (_.isNull(x) || _.isNull(y)) { return { url: null }; } - const params = { x: x.value, y: y.value, query }; + const params = { x: x.value, y: _.join(_.map(y, "value"), ","), query }; if (!_.isNull(group)) { params.group = _.join(_.map(group, "value"), ","); } if (!_.isNull(aggregation)) { - params.agg = aggregation.value; + params.agg = aggregation; + if (aggregation === "rolling") { + if (rollingWindow && parseInt(rollingWindow)) { + params.rollingWin = parseInt(rollingWindow); + } else { + return { url: null, error: "Aggregation (rolling) requires a window" }; + } + if (rollingComputation) { + params.rollingComp = rollingComputation; + } else { + return { + url: null, + error: "Aggregation (rolling) requires a computation", + }; + } + } } return { url: buildURLString(`/dtale/chart-data/${dataId}`, params) }; } -const baseState = ({ query, x, y, group, aggregation, chartType, chartPerGroup }) => ({ +const baseState = ({ query, x, y, group, aggregation }) => ({ x: x ? { value: x } : null, - y: y ? { value: y } : null, + y: y ? _.map(y, y2 => ({ value: y2 })) : null, group: group ? _.map(group, g => ({ value: g })) : null, - aggregation: aggregation ? _.find(AGGREGATIONS, { value: aggregation }) : null, - chartType: { value: chartType || "line" }, + aggregation, + rollingComputation: null, + rollingWindow: "4", url: null, - zoomed: null, - chartPerGroup: chartPerGroup === "true", query, + error: null, }); require("./Charts.css"); @@ -62,9 +58,6 @@ class ReactCharts extends React.Component { constructor(props) { super(props); this.state = baseState(_.get(props, "chartData") || {}); - this.viewTimeDetails = this.viewTimeDetails.bind(this); - this.resetZoom = this.resetZoom.bind(this); - this.renderLabel = this.renderLabel.bind(this); this.renderSelect = this.renderSelect.bind(this); } @@ -111,101 +104,34 @@ class ReactCharts extends React.Component { ); } - resetZoom() { - const charts = _.get(this, "_chart.state.charts"); - if (charts) { - _.forEach(charts, c => { - delete c.options.scales.xAxes[0].ticks; - c.update(); - }); - this.setState({ zoomed: false }); - } - } - - viewTimeDetails(evt) { - const charts = _.get(this, "_chart.state.charts"); - if (charts) { - const selectedChart = _.find(charts, c => !_.isEmpty(c.getElementAtEvent(evt))); - const selectedPoint = _.head(selectedChart.getElementAtEvent(evt)); - if (selectedPoint) { - const ticks = { - min: selectedChart.data.labels[_.max([0, selectedPoint._index - 10])], - max: selectedChart.data.labels[_.min([selectedChart.data.labels.length - 1, selectedPoint._index + 10])], - }; - _.forEach(charts, c => { - c.options.scales.xAxes[0].ticks = ticks; - c.update(); - }); - let zoomed = `${ticks.min} - ${ticks.max}`; - if (isDateCol(_.find(this.state.columns, { name: this.state.x.value }).dtype)) { - const buildLabel = x => moment(new Date(x)).format("YYYY-MM-DD"); - zoomed = `${buildLabel(ticks.min)} - ${buildLabel(ticks.max)}`; - } - this.setState({ zoomed }); - } - } - } - - renderLabel() { - const { url, error, zoomed } = this.state; - return ( - - - -
    - {`Zoomed: ${zoomed}`} - {"X"} -
    -
    -
    - ); - } - render() { - const { columns } = this.state; + const { columns, query, error } = this.state; if (_.isEmpty(columns)) { - return null; - } - const additionalOptions = {}; - if (this.state.chartType.value === "line") { - additionalOptions.onClick = this.viewTimeDetails; + return error; } return (
    - X: -
    {this.renderSelect("x", ["y", "group"])}
    - Y: -
    {this.renderSelect("y", ["x", "group"])}
    - Group: -
    {this.renderSelect("group", ["x", "y"], true)}
    - Aggregation: -
    -
    - this.setState({ query: e.target.value })} />
    +
    +
    + X: +
    {this.renderSelect("x", ["y", "group"])}
    + Y: +
    {this.renderSelect("y", ["x", "group"], true)}
    + Group: +
    {this.renderSelect("group", ["x", "y"], true)}
    +
    +
    + this.setState(state)} {...this.state} />
    -
    - Chart: -
    -
    - this.setState({ chartPerGroup: e.target.checked })} - /> -
    - -
    -
    -
    {this.renderLabel()}
    -
    (this._chart = r)} + chartType={_.get(this.props, "chartData.chartType")} + chartPerGroup={_.get(this.props, "chartData.chartPerGroup")} visible={_.get(this.props, "chartData.visible", false)} - url={this.state.url} - columns={this.state.columns} - chartType={_.get(this.state, "chartType.value")} - chartPerGroup={this.state.chartPerGroup} - x={_.get(this.state, "x.value")} - y={_.get(this.state, "y.value")} - group={_.join(_.map(_.get(this.state, "group") || [], "value"), ",")} - additionalOptions={additionalOptions} + {...this.state} height={450} />
    diff --git a/static/popups/charts/ChartsBody.jsx b/static/popups/charts/ChartsBody.jsx index 917121f3b..4fa815f59 100644 --- a/static/popups/charts/ChartsBody.jsx +++ b/static/popups/charts/ChartsBody.jsx @@ -1,66 +1,121 @@ -import $ from "jquery"; +/* eslint max-lines: "off" */ import _ from "lodash"; +import moment from "moment"; import PropTypes from "prop-types"; import React from "react"; +import Select, { createFilter } from "react-select"; import { Bouncer } from "../../Bouncer"; +import ConditionalRender from "../../ConditionalRender"; +import { JSAnchor } from "../../JSAnchor"; import { RemovableError } from "../../RemovableError"; import chartUtils from "../../chartUtils"; +import { isDateCol } from "../../dtale/gridUtils"; import { fetchJson } from "../../fetcher"; +import { toggleBouncer } from "../../toggleUtils"; +import AxisEditor from "./AxisEditor"; +import ChartLabel from "./ChartLabel"; import WordcloudBody from "./WordcloudBody"; -function toggleBouncer() { - $("#chart-bouncer").toggle(); - $("#coveragePopup").toggle(); +function chartType(state) { + return _.get(state, "chartType.value"); } -function createChartCfg(ctx, { data }, { columns, x, y, additionalOptions, chartType, configHandler }) { +function chartTypes({ y }) { + const types = ["line", "bar", "stacked"]; + const yList = _.concat([], y || []); + if (_.size(yList) < 2) { + types.push("scatter"); + types.push("pie"); + } + types.push("wordcloud"); + return types; +} + +function createChartCfg(ctx, data, { columns, x, y, additionalOptions, chartType, configHandler }, funcs = {}) { let cfg = null; + const mainProps = { + columns, + x: _.get(x, "value"), + y: _.map(y || [], "value"), + additionalOptions, + configHandler, + }; switch (chartType) { case "bar": - cfg = chartUtils.createBarCfg({ data }, { columns, x, y, additionalOptions, configHandler }); + cfg = chartUtils.createBarCfg(data, mainProps); break; case "stacked": - cfg = chartUtils.createStackedCfg({ data }, { columns, x, y, additionalOptions, configHandler }); + cfg = chartUtils.createStackedCfg(data, mainProps); + break; + case "scatter": + cfg = chartUtils.createScatterCfg(data, mainProps); break; case "pie": - cfg = chartUtils.createPieCfg({ data }, { columns, x, y, additionalOptions, configHandler }); + cfg = chartUtils.createPieCfg(data, mainProps); break; case "line": - default: - cfg = chartUtils.createLineCfg({ data }, { columns, x, y, additionalOptions, configHandler }); + default: { + if (_.has(funcs, "viewTimeDetails")) { + mainProps.additionalOptions = _.assignIn(additionalOptions || {}, { + onClick: funcs.viewTimeDetails, + }); + } + cfg = chartUtils.createLineCfg(data, mainProps); break; + } } return chartUtils.createChart(ctx, cfg); } -function createCharts(data, props) { +function createCharts(data, props, state, funcs = {}) { if (_.isEmpty(_.get(data, "data", {}))) { return null; } - if (props.chartType === "wordcloud") { + const chartTypeVal = chartType(state); + if (chartTypeVal === "wordcloud") { return null; } - if (props.chartPerGroup) { + if (state.chartPerGroup) { return _.map(_.get(data, "data", {}), (series, seriesKey) => { - const mainProps = _.pick(props, ["columns", "x", "y", "additionalOptions", "chartType", "configHandler"]); + const mainProps = _.pick(props, ["columns", "x", "y", "additionalOptions", "configHandler"]); + mainProps.chartType = chartTypeVal; mainProps.additionalOptions = _.assignIn(mainProps.additionalOptions, { title: { display: true, text: seriesKey }, }); - const builder = ctx => createChartCfg(ctx, { data: { all: series } }, mainProps); + const subData = { data: { all: series }, min: data.min, max: data.max }; + const builder = ctx => createChartCfg(ctx, subData, mainProps, funcs); return chartUtils.chartWrapper(`chartCanvas-${seriesKey}`, null, builder); }); } - const builder = ctx => createChartCfg(ctx, data, props); + const builder = ctx => createChartCfg(ctx, data, _.assignIn({}, props, { chartType: chartTypeVal }), funcs); return [chartUtils.chartWrapper("chartCanvas", null, builder)]; } +function sortBars(data, props, state) { + _.forEach(_.get(data, "data", {}), series => { + const [sortedX, sortedY] = _.unzip(_.sortBy(_.zip(series.x, series[props.y]), "1")); + series.x = sortedX; + series[props.y] = sortedY; + }); + return createCharts(data, props, state); +} + class ChartsBody extends React.Component { constructor(props) { super(props); this.mounted = false; - this.state = { charts: null, error: null }; - this.buildChart = this.buildChart.bind(this); + this.state = { + chartType: { value: props.chartType || "line" }, + chartPerGroup: props.chartPerGroup === "true", + charts: null, + error: null, + zoomed: null, + }; + _.forEach( + ["buildChart", "sortBars", "updateAxis", "viewTimeDetails", "resetZoom", "renderLabel", "renderControls"], + f => (this[f] = this[f].bind(this)) + ); } shouldComponentUpdate(newProps, newState) { @@ -72,11 +127,12 @@ class ChartsBody extends React.Component { return true; } - if (this.props.chartType == "wordcloud" && !_.isEqual(this.state.data, newState.data)) { + const selectedState = ["chartType", "chartPerGroup", "data", "zoomed"]; + if (!_.isEqual(_.pick(this.state, selectedState), _.pick(newState, selectedState))) { return true; } - if (this.state.chart != newState.chart) { + if (this.state.charts != newState.charts) { // Don't re-render if we've only changed the chart. return false; } @@ -84,7 +140,7 @@ class ChartsBody extends React.Component { return false; // Otherwise, use the default react behaviour. } - componentDidUpdate(prevProps) { + componentDidUpdate(prevProps, prevState) { if (!this.props.visible) { return; } @@ -92,13 +148,18 @@ class ChartsBody extends React.Component { this.buildChart(); return; } - const selectedProps = ["chartType", "chartPerGroup"]; - if (!_.isEqual(_.pick(this.props, selectedProps), _.pick(prevProps, selectedProps))) { - _.forEach(this.state.charts || [], c => c.destroy()); - this.setState({ - error: null, - charts: createCharts(_.get(this.state, "data", {}), this.props), - }); + const selectedState = ["chartType", "chartPerGroup"]; + if (!_.isEqual(_.pick(this.state, selectedState), _.pick(prevState, selectedState))) { + if (chartType(this.state) !== chartType({ state: prevState }) && chartType({ state: prevState }) === "scatter") { + this.buildChart(); //need to reload chart data because scatter charts allow duplicates + } else { + _.forEach(this.state.charts || [], c => c.destroy()); + const funcs = _.pick(this, ["viewTimeDetails"]); + this.setState({ + error: null, + charts: createCharts(_.get(this.state, "data", {}), this.props, this.state, funcs), + }); + } } } @@ -115,10 +176,10 @@ class ChartsBody extends React.Component { if (_.isNil(this.props.url)) { return; } - toggleBouncer(); + toggleBouncer(["chart-bouncer", "coveragePopup"]); _.forEach(this.state.charts || [], c => c.destroy()); - fetchJson(this.props.url, fetchedChartData => { - toggleBouncer(); + fetchJson(`${this.props.url}${chartType(this.state) === "scatter" ? "&allowDupes=true" : ""}`, fetchedChartData => { + toggleBouncer(["chart-bouncer", "coveragePopup"]); if (this.mounted) { if (fetchedChartData.error) { this.setState({ @@ -138,41 +199,161 @@ class ChartsBody extends React.Component { this.setState({ error: null, data: fetchedChartData, - charts: createCharts(fetchedChartData, this.props), + charts: createCharts(fetchedChartData, this.props, this.state, _.pick(this, ["viewTimeDetails"])), }); } }); } + resetZoom() { + const { charts } = this.state; + if (charts) { + _.forEach(charts, c => { + delete c.options.scales.xAxes[0].ticks; + c.update(); + }); + this.setState({ zoomed: null }); + } + } + + viewTimeDetails(evt) { + const { charts } = this.state; + if (charts) { + const selectedChart = _.find(charts, c => !_.isEmpty(c.getElementAtEvent(evt))); + if (selectedChart) { + const selectedPoint = _.head(selectedChart.getElementAtEvent(evt)); + if (selectedPoint) { + const ticks = { + min: selectedChart.data.labels[_.max([0, selectedPoint._index - 10])], + max: selectedChart.data.labels[_.min([selectedChart.data.labels.length - 1, selectedPoint._index + 10])], + }; + _.forEach(charts, c => { + c.options.scales.xAxes[0].ticks = ticks; + c.update(); + }); + let zoomed = `${ticks.min} - ${ticks.max}`; + if (isDateCol(_.find(this.props.columns, { name: this.props.x.value }).dtype)) { + const buildLabel = x => moment(new Date(x)).format("YYYY-MM-DD"); + zoomed = `${buildLabel(ticks.min)} - ${buildLabel(ticks.max)}`; + } + this.setState({ zoomed }); + } + } + } + } + + renderLabel() { + const { data, error, zoomed } = this.state; + return ( + + + +
    + {`Zoomed: ${zoomed}`} + {"X"} +
    +
    +
    + ); + } + + sortBars() { + _.forEach(this.state.charts || [], c => c.destroy()); + this.setState({ + charts: sortBars(_.get(this.state, "data", {}), this.props, this.state), + }); + } + + updateAxis(settings) { + if (_.isEqual(settings, _.pick(this.state.data, ["min", "max"]))) { + return; + } + _.forEach(this.state.charts || [], c => c.destroy()); + const updatedData = _.assignIn({}, _.get(this.state, "data", {}), settings); + this.setState({ + data: updatedData, + charts: createCharts(updatedData, this.props, this.state, _.pick(this, ["viewTimeDetails"])), + }); + } + + renderControls() { + if (this.props.showControls) { + const showBarSort = chartType(this.state) === "bar" && _.isNull(this.props.group) && !_.isEmpty(this.state.data); + return [ +
    + Chart: +
    +
    + this.setState({ chartPerGroup: e.target.checked })} + /> +
    + + + + + +
    , +
    +
    {this.renderLabel()}
    +
    , + ]; + } + return null; + } + render() { + if (!this.props.visible) { + return null; + } let charts = null; - if (this.props.chartPerGroup || (this.props.chartType === "wordcloud" && this.props.group)) { + if (chartType(this.state) === "wordcloud") { + charts = ; + } else if (this.state.chartPerGroup) { charts = (
    - {_.map(_.keys(_.get(this.state, "data.data", {})), k => ( + {_.map(_.get(this.state, "data.data", {}), (_v, k) => (
    - {this.props.chartType !== "wordcloud" && } - {this.props.chartType === "wordcloud" && ( - - )} +
    ))}
    ); - } else if (this.props.chartType === "wordcloud") { - charts = ; } else { charts = ; } - return ( -
    + return [ + this.renderControls(), +
    {this.state.error} {charts} -
    - ); +
    , + ]; } } @@ -180,21 +361,26 @@ ChartsBody.displayName = "ChartsBody"; ChartsBody.propTypes = { url: PropTypes.string, columns: PropTypes.arrayOf(PropTypes.object), - x: PropTypes.string, // eslint-disable-line react/no-unused-prop-types - y: PropTypes.string, // eslint-disable-line react/no-unused-prop-types - group: PropTypes.string, // eslint-disable-line react/no-unused-prop-types + x: PropTypes.object, // eslint-disable-line react/no-unused-prop-types + y: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/no-unused-prop-types + group: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/no-unused-prop-types + aggregation: PropTypes.string, + rollingWindow: PropTypes.string, + rollingComputation: PropTypes.string, chartType: PropTypes.string, chartPerGroup: PropTypes.bool, visible: PropTypes.bool.isRequired, height: PropTypes.number, additionalOptions: PropTypes.object, // eslint-disable-line react/no-unused-prop-types configHandler: PropTypes.func, + showControls: PropTypes.bool, }; ChartsBody.defaultProps = { height: 400, chartPerGroup: false, chartType: "line", configHandler: config => config, + showControls: true, }; export default ChartsBody; diff --git a/static/popups/charts/WordcloudBody.jsx b/static/popups/charts/WordcloudBody.jsx index f8d4659a0..c8759337a 100644 --- a/static/popups/charts/WordcloudBody.jsx +++ b/static/popups/charts/WordcloudBody.jsx @@ -3,49 +3,77 @@ import PropTypes from "prop-types"; import React from "react"; import ReactWordcloud from "react-wordcloud"; +import chartUtils from "../../chartUtils"; +import { isDateCol } from "../../dtale/gridUtils"; + class WordcloudBody extends React.Component { constructor(props) { super(props); } render() { - const { chartType, seriesKey } = this.props; - if (chartType !== "wordcloud") { - return null; - } - const series = _.get(this.props, ["data", "data", seriesKey], {}); - if (_.isEmpty(series)) { + const { chartType, x, y, columns } = this.props; + if (chartType.value !== "wordcloud") { return null; } + const yProps = _.map(y || [], "value"); + const colWidth = _.size(_.get(this.props, "data.data", {})) * _.size(yProps) == 1 ? "12" : "6"; return ( -
    - {seriesKey !== "all" && {seriesKey}} -
    - ({ - text: _.truncate(l + "", { length: 24 }), - fullText: l + "", - value: series.y[i], - })), - "value" - )} - callbacks={{ - getWordTooltip: ({ fullText, value }) => `${fullText} (${value})`, - }} - /> -
    +
    + {_.flatMap(_.get(this.props, "data.data", {}), (series, seriesKey) => + _.map(yProps, yProp => { + if (_.isEmpty(series[yProp] || [])) { + return null; + } + const labels = []; + if (seriesKey !== "all") { + labels.push(seriesKey); + } + if (_.size(yProps) > 1) { + labels.push(yProp); + } + const hasLabel = _.size(labels) > 0; + return ( +
    +
    + {hasLabel && {_.join(labels, " - ")}} +
    + { + let labelText = l + ""; + if (isDateCol(_.find(columns, { name: x.value }).dtype)) { + labelText = chartUtils.timestampLabel(l); + } + return { + text: _.truncate(labelText, { length: 24 }), + fullText: labelText, + value: series[yProp][i], + }; + }), + "value" + )} + callbacks={{ + getWordTooltip: ({ fullText, value }) => `${fullText} (${value})`, + }} + /> +
    +
    +
    + ); + }) + )}
    ); } @@ -54,9 +82,11 @@ class WordcloudBody extends React.Component { WordcloudBody.displayName = "WordcloudBody"; WordcloudBody.propTypes = { data: PropTypes.object, // eslint-disable-line react/no-unused-prop-types - seriesKey: PropTypes.string, - group: PropTypes.string, // eslint-disable-line react/no-unused-prop-types - chartType: PropTypes.string, + columns: PropTypes.arrayOf(PropTypes.object), + x: PropTypes.object, + y: PropTypes.arrayOf(PropTypes.object), + group: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/no-unused-prop-types + chartType: PropTypes.object, height: PropTypes.number, }; export default WordcloudBody; diff --git a/static/popups/correlations/CorrelationScatterStats.jsx b/static/popups/correlations/CorrelationScatterStats.jsx index cebd7ec21..eca701573 100644 --- a/static/popups/correlations/CorrelationScatterStats.jsx +++ b/static/popups/correlations/CorrelationScatterStats.jsx @@ -46,8 +46,8 @@ CorrelationScatterStats.propTypes = { selectedCols: PropTypes.arrayOf(PropTypes.string), date: PropTypes.string, stats: PropTypes.shape({ - pearson: PropTypes.number, - spearman: PropTypes.number, + pearson: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + spearman: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), correlated: PropTypes.number, only_in_s0: PropTypes.number, only_in_s1: PropTypes.number, diff --git a/static/popups/correlations/correlationsUtils.jsx b/static/popups/correlations/correlationsUtils.jsx index 7f628d927..81f8af962 100644 --- a/static/popups/correlations/correlationsUtils.jsx +++ b/static/popups/correlations/correlationsUtils.jsx @@ -1,84 +1,28 @@ import chroma from "chroma-js"; -import $ from "jquery"; import _ from "lodash"; import chartUtils from "../../chartUtils"; -import { formatScatterPoints, getMax, getMin } from "../scatterChartUtils"; -function toggleBouncer() { - $("#scatter-bouncer").toggle(); - $("#rawScatterChart").toggle(); -} - -const pointFormatter = (xProp, yProp) => point => ({ - x: point[xProp], - y: point[yProp], - index: point.index, -}); const colorScale = chroma.scale(["red", "yellow", "green"]).domain([-1, 0, 1]); const percent = num => (num === "N/A" ? num : `${_.round(num * 100, 2)}%`); -function createScatter(ctx, chartData, xProp, yProp, label, onClick) { - const formatter = pointFormatter(xProp, yProp); - const scatterData = formatScatterPoints(chartData, formatter); +function createScatter(ctx, data, xProp, yProp, onClick) { + const builder = data => + _.map(_.zip(data.all.x, data.all[yProp], data.all.index), ([xVal, yVal, index]) => ({ x: xVal, y: yVal, index })); + const scatterCfg = chartUtils.createScatterCfg(data, { x: xProp, y: [yProp] }, builder); + scatterCfg.options.tooltips.callbacks.title = (tooltipItems, data) => [ + [`Index: ${data.datasets[tooltipItems[0].datasetIndex].data[tooltipItems[0].index].index}`], + ]; + delete scatterCfg.options.scales.xAxes[0].ticks; + delete scatterCfg.options.scales.yAxes[0].ticks; + scatterCfg.options.onClick = onClick; // eslint-disable-next-line new-cap - const chart = chartUtils.createChart(ctx, { - type: "scatter", - data: { - datasets: [_.assign({ label, xLabels: [xProp], yLabels: [yProp] }, scatterData)], - }, - options: { - tooltips: { - callbacks: { - title: (tooltipItems, data) => [ - [`Index: ${data.datasets[tooltipItems[0].datasetIndex].data[tooltipItems[0].index].index}`], - ], - label: (tooltipItem, data) => { - const chartData = data.datasets[tooltipItem.datasetIndex]; - const pointData = chartData.data[tooltipItem.index]; - return [ - `${chartData.xLabels[0]}: ${_.round(pointData.x, 4)}`, - `${chartData.yLabels[0]}: ${_.round(pointData.y, 4)}`, - ]; - }, - }, - }, - scales: { - xAxes: [ - { - ticks: { - min: getMin(scatterData.data, "x"), - max: getMax(scatterData.data, "x"), - }, - scaleLabel: { display: true, labelString: xProp }, - }, - ], - yAxes: [ - { - ticks: { - min: getMin(scatterData.data, "y"), - max: getMax(scatterData.data, "y"), - }, - scaleLabel: { display: true, labelString: yProp }, - }, - ], - }, - legend: { display: false }, - pan: { enabled: true, mode: "x" }, - zoom: { enabled: true, mode: "x" }, - maintainAspectRatio: false, - responsive: false, - showLines: false, - onClick, - }, - }); + const chart = chartUtils.createChart(ctx, scatterCfg); return chart; } export default { - toggleBouncer, colorScale, createScatter, percent, - pointFormatter, }; diff --git a/static/popups/scatterChartUtils.js b/static/scatterChartUtils.js similarity index 78% rename from static/popups/scatterChartUtils.js rename to static/scatterChartUtils.js index f413a7a7c..e066eeb68 100644 --- a/static/popups/scatterChartUtils.js +++ b/static/scatterChartUtils.js @@ -1,5 +1,7 @@ import _ from "lodash"; +const basePointFormatter = (xProp, yProp) => point => _.assignIn(point, { x: point[xProp], y: point[yProp] }); + function formatScatterPoints(chartData, formatter = p => p, highlight = () => false, filter = () => false) { const data = [], pointBackgroundColor = [], @@ -57,14 +59,14 @@ function formatScatterPoints(chartData, formatter = p => p, highlight = () => fa }; } -function getMin(data, prop) { - const min = _.min(_.map(data, prop)); +function getScatterMin(data, prop = null) { + const min = _.min(_.isNull(prop) ? data : _.map(data, prop)); return _.floor(min + (min % 1 ? 0 : -1)) - 0.5; } -function getMax(data, prop) { - const max = _.max(_.map(data, prop)); +function getScatterMax(data, prop = null) { + const max = _.max(_.isNull(prop) ? data : _.map(data, prop)); return _.ceil(max + (max % 1 ? 0 : 1)) + 0.5; } -export { formatScatterPoints, getMin, getMax }; +export { basePointFormatter, formatScatterPoints, getScatterMin, getScatterMax }; diff --git a/static/toggleButtonUtils.js b/static/toggleUtils.js similarity index 58% rename from static/toggleButtonUtils.js rename to static/toggleUtils.js index 3f11b469e..300009d0c 100644 --- a/static/toggleButtonUtils.js +++ b/static/toggleUtils.js @@ -1,7 +1,12 @@ import _ from "lodash"; +import $ from "jquery"; function buildButton(active, activate, disabled = false) { return { className: `btn btn-primary ${active ? "active" : ""}`, onClick: active ? _.noop : activate, disabled }; } -export { buildButton }; +function toggleBouncer(ids) { + _.forEach(ids, id => $("#" + id).toggle()); +} + +export { buildButton, toggleBouncer }; diff --git a/tests/dtale/test_views.py b/tests/dtale/test_views.py index dda43362b..b6e9fdf26 100644 --- a/tests/dtale/test_views.py +++ b/tests/dtale/test_views.py @@ -479,10 +479,10 @@ def test_get_correlations_ts(unittest, rolling_data): expected = { 'data': {'all': { 'x': [946702800000, 946789200000, 946875600000, 946962000000, 947048400000], - 'y': [1.0, 1.0, 1.0, 1.0, 1.0] + 'corr': [1.0, 1.0, 1.0, 1.0, 1.0] }}, - 'max': 1.0, - 'min': 1.0, + 'max': {'corr': 1.0, 'x': 947048400000}, + 'min': {'corr': 1.0, 'x': 946702800000}, 'success': True, } unittest.assertEqual(response_data, expected, 'should return timeseries correlation') @@ -530,13 +530,15 @@ def test_get_scatter(unittest): 'only_in_s1': 0, 'spearman': 0.9999999999999999 }, - data=[ - {u'index': 0, u'foo': 0, u'bar': 0}, - {u'index': 1, u'foo': 1, u'bar': 1}, - {u'index': 2, u'foo': 2, u'bar': 2}, - {u'index': 3, u'foo': 3, u'bar': 3}, - {u'index': 4, u'foo': 4, u'bar': 4} - ], + data={ + 'all': { + 'bar': [0, 1, 2, 3, 4], + 'index': [0, 1, 2, 3, 4], + 'x': [0, 1, 2, 3, 4] + } + }, + max={'bar': 4, 'index': 4, 'x': 4}, + min={'bar': 0, 'index': 0, 'x': 0}, x='foo' ) unittest.assertEqual(response_data, expected, 'should return scatter') @@ -579,7 +581,9 @@ def test_get_scatter(unittest): @pytest.mark.unit -def test_get_chart_data(unittest, test_data): +def test_get_chart_data(unittest, test_data, rolling_data): + import dtale.views as views + test_data = pd.DataFrame(build_ts_data(size=50), columns=['date', 'security_id', 'foo', 'bar']) with app.test_client() as c: with mock.patch('dtale.views.DATA', {c.port: test_data}): @@ -589,10 +593,11 @@ def test_get_chart_data(unittest, test_data): expected = { u'data': {u'all': { u'x': [946702800000, 946789200000, 946875600000, 946962000000, 947048400000], - u'y': [50, 50, 50, 50, 50] + u'security_id': [50, 50, 50, 50, 50] }}, - u'min': 50, - u'max': 50 + u'max': {'security_id': 50, 'x': 947048400000}, + u'min': {'security_id': 50, 'x': 946702800000}, + u'success': True, } unittest.assertEqual(response_data, expected, 'should return chart data') @@ -603,11 +608,21 @@ def test_get_chart_data(unittest, test_data): params = dict(x='date', y='security_id', group='baz', agg='mean') response = c.get('/dtale/chart-data/{}'.format(c.port), query_string=params) response_data = json.loads(response.data) - assert response_data['min'] == 24.5 - assert response_data['max'] == 24.5 + assert response_data['min']['security_id'] == 24.5 + assert response_data['max']['security_id'] == 24.5 assert response_data['data']['baz']['x'][-1] == 947048400000 - assert len(response_data['data']['baz']['y']) == 5 - assert sum(response_data['data']['baz']['y']) == 122.5 + assert len(response_data['data']['baz']['security_id']) == 5 + assert sum(response_data['data']['baz']['security_id']) == 122.5 + + df, _ = views.format_data(rolling_data) + with app.test_client() as c: + with ExitStack() as stack: + stack.enter_context(mock.patch('dtale.views.DATA', {c.port: df})) + stack.enter_context(mock.patch('dtale.views.DTYPES', {c.port: views.build_dtypes_state(df)})) + params = dict(x='date', y='0', agg='rolling', rollingWin=10, rollingComp='count') + response = c.get('/dtale/chart-data/{}'.format(c.port), query_string=params) + response_data = json.loads(response.data) + assert response_data['success'] with app.test_client() as c: with mock.patch('dtale.views.DATA', {c.port: test_data}): @@ -642,6 +657,19 @@ def test_get_chart_data(unittest, test_data): response_data['error'], 'query "security_id == 51" found no data, please alter' ) + df = pd.DataFrame([dict(a=i, b=i) for i in range(15500)]) + df, _ = views.format_data(df) + with app.test_client() as c: + with ExitStack() as stack: + stack.enter_context(mock.patch('dtale.views.DATA', {c.port: df})) + stack.enter_context(mock.patch('dtale.views.DTYPES', {c.port: views.build_dtypes_state(df)})) + params = dict(x='a', y='b', allowDupes=True) + response = c.get('/dtale/chart-data/{}'.format(c.port), query_string=params) + response_data = json.loads(response.data) + unittest.assertEqual( + response_data['error'], 'Dataset exceeds 15,000 records, cannot render. Please apply filter...' + ) + @pytest.mark.unit def test_version_info():