diff --git a/.loki/reference/chrome_laptop_static_viz_ComboChart_Line_Replace_Missing_Values_Zero.png b/.loki/reference/chrome_laptop_static_viz_ComboChart_Line_Replace_Missing_Values_Zero.png new file mode 100644 index 0000000000000000000000000000000000000000..c6ac20f1b9fa7c8398b8c1f02baf1eb682c847a8 Binary files /dev/null and b/.loki/reference/chrome_laptop_static_viz_ComboChart_Line_Replace_Missing_Values_Zero.png differ diff --git a/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Combo.png b/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Combo.png index 4b2568d86451fd35fc648fd616a5a599366a544c..85cb90c2d43e23a8839e219509bdffebae17321e 100644 Binary files a/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Combo.png and b/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Combo.png differ diff --git a/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Combo_Log.png b/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Combo_Log.png index 69d8cbf2dac9c95e4ac376f4792827e29e05e1f3..e86878a5683d1abfab9f36cc930fa7256d8b970b 100644 Binary files a/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Combo_Log.png and b/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Combo_Log.png differ diff --git a/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Combo_Power.png b/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Combo_Power.png index 2e347935ba95e74d0081449ecfa0dd8855cc8a48..8055b4ce7888c9726003b51211f881ae9fb2beda 100644 Binary files a/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Combo_Power.png and b/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Combo_Power.png differ diff --git a/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Multi_Series_Area.png b/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Multi_Series_Area.png index 72676fb8bc0b828ae484a0dd6e7b21e5fddce111..2785dcd9bd70462860f9db0bae15fbd8d31bdbf0 100644 Binary files a/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Multi_Series_Area.png and b/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Multi_Series_Area.png differ diff --git a/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Multi_Series_Line.png b/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Multi_Series_Line.png index 25b7fa73a8692a69d605c6ed4660d8be1f4535f1..d88a85438cbe66b1360adaeb2fc0db43ae9db622 100644 Binary files a/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Multi_Series_Line.png and b/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Multi_Series_Line.png differ diff --git a/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Multi_Series_Normalized_Stacked_Area.png b/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Multi_Series_Normalized_Stacked_Area.png index fd1101ca108f211aa12b65abdbead2b02a120c59..ccc89a4bd3e6b9a2ded420c7bc546491d6ff55cf 100644 Binary files a/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Multi_Series_Normalized_Stacked_Area.png and b/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Multi_Series_Normalized_Stacked_Area.png differ diff --git a/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Multi_Series_Normalized_Stacked_Bar.png b/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Multi_Series_Normalized_Stacked_Bar.png index 63e03afedac215284b32670ff6615416649fa27c..ce4cba1653b0215be8b1514ed80fc256c8e6c979 100644 Binary files a/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Multi_Series_Normalized_Stacked_Bar.png and b/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Multi_Series_Normalized_Stacked_Bar.png differ diff --git a/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Multi_Series_Stacked_Area.png b/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Multi_Series_Stacked_Area.png index e530249218408c21ad03deb6d26e73b16ea86661..5ee3084d007da6a6645cc2ddd3c0df5d4dae7b3f 100644 Binary files a/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Multi_Series_Stacked_Area.png and b/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Multi_Series_Stacked_Area.png differ diff --git a/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Single_Series_Area.png b/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Single_Series_Area.png index 3f36750790d58ccee50c268d918108a1fa2198d7..f26d5b6f954188928173b64f6de39158d72c6bc6 100644 Binary files a/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Single_Series_Area.png and b/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Single_Series_Area.png differ diff --git a/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Single_Series_Line.png b/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Single_Series_Line.png index 4e283cad217cec61c303bfc172b7c7b94a71c1e9..dfd36aaadfcf5b126b5c38ba3836ee9f7ae49808 100644 Binary files a/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Single_Series_Line.png and b/.loki/reference/chrome_laptop_static_viz_ComboChart_Trend_Single_Series_Line.png differ diff --git a/frontend/src/metabase/static-viz/components/ComboChart/ComboChart.stories.tsx b/frontend/src/metabase/static-viz/components/ComboChart/ComboChart.stories.tsx index 0d8f2c7d29624eb379ba9112a08f2fe132464fa7..1f963ffaa6283e183efe8db1dc835adf41e70038 100644 --- a/frontend/src/metabase/static-viz/components/ComboChart/ComboChart.stories.tsx +++ b/frontend/src/metabase/static-viz/components/ComboChart/ComboChart.stories.tsx @@ -604,6 +604,7 @@ HistogramTicks45Degrees.args = { dashcardSettings: {}, renderingContext, }; + export const HistogramTicks90Degrees = Template.bind({}); HistogramTicks90Degrees.args = { rawSeries: data.histogramTicks90Degrees as any, @@ -618,6 +619,13 @@ LineUnpinFromZero.args = { renderingContext, }; +export const LineReplaceMissingValuesZero = Template.bind({}); +LineReplaceMissingValuesZero.args = { + rawSeries: data.lineReplaceMissingValuesZero as any, + dashcardSettings: {}, + renderingContext, +}; + export const Default = Template.bind({}); Default.args = { rawSeries: data.messedUpAxis as any, diff --git a/frontend/src/metabase/static-viz/components/ComboChart/stories-data/index.ts b/frontend/src/metabase/static-viz/components/ComboChart/stories-data/index.ts index 13360c413038e514d83d05c591892d519d2e19d7..563168b4c7d53a775d181d1f47324ad14df8c6b6 100644 --- a/frontend/src/metabase/static-viz/components/ComboChart/stories-data/index.ts +++ b/frontend/src/metabase/static-viz/components/ComboChart/stories-data/index.ts @@ -51,6 +51,7 @@ import lineLinearXScaleUnsorted from "./line-linear-x-scale-unsorted.json"; import lineLinearXScale from "./line-linear-x-scale.json"; import lineLogYScaleNegative from "./line-log-y-scale-negative.json"; import lineLogYScale from "./line-log-y-scale.json"; +import lineReplaceMissingValuesZero from "./line-replace-missing-values-zero.json"; import lineShowDotsAuto from "./line-show-dots-auto.json"; import lineShowDotsOff from "./line-show-dots-off.json"; import lineShowDotsOn from "./line-show-dots-on.json"; @@ -170,4 +171,5 @@ export const data = { histogramTicks45Degrees, histogramTicks90Degrees, lineUnpinFromZero, + lineReplaceMissingValuesZero, }; diff --git a/frontend/src/metabase/static-viz/components/ComboChart/stories-data/line-replace-missing-values-zero.json b/frontend/src/metabase/static-viz/components/ComboChart/stories-data/line-replace-missing-values-zero.json new file mode 100644 index 0000000000000000000000000000000000000000..5faf44b78be236b9600273c5347927879549213c --- /dev/null +++ b/frontend/src/metabase/static-viz/components/ComboChart/stories-data/line-replace-missing-values-zero.json @@ -0,0 +1,175 @@ +[ + { + "card": { + "public_uuid": null, + "parameter_usage_count": 0, + "created_at": "2024-05-01T23:25:14.654039Z", + "parameters": [], + "metabase_version": "v0.48.1-SNAPSHOT (8053de5)", + "collection": null, + "visualization_settings": { + "series_settings": { + "Y": { + "line.missing": "zero" + } + }, + "graph.dimensions": ["X"], + "graph.metrics": ["Y"] + }, + "collection_preview": true, + "entity_id": "hr7YTZePecARmmOlJMmYM", + "display": "line", + "parameter_mappings": [], + "id": 197, + "dataset_query": { + "database": 1, + "type": "native", + "native": { + "query": "SELECT DATE '2020-01-01' AS x, 10 AS y\nUNION ALL\nSELECT DATE '2023-01-01', 10\nUNION ALL\nSELECT DATE '2024-01-01', 10", + "template-tags": {} + } + }, + "cache_ttl": null, + "embedding_params": null, + "made_public_by_id": null, + "updated_at": "2024-05-01T23:25:14.654039Z", + "moderation_reviews": [], + "creator_id": 1, + "average_query_time": null, + "type": "question", + "dashboard_count": 0, + "last_query_start": null, + "name": "replace missing values with zero", + "query_type": "native", + "collection_id": null, + "enable_embedding": false, + "database_id": 1, + "can_write": true, + "initially_published_at": null, + "result_metadata": null, + "table_id": null, + "collection_position": null, + "view_count": 0, + "archived": false, + "description": null, + "cache_invalidated_at": null, + "displayIsLocked": true + }, + "data": { + "rows": [ + ["2020-01-01T00:00:00-03:00", 10], + ["2023-01-01T00:00:00-03:00", 10], + ["2024-01-01T00:00:00-03:00", 10] + ], + "cols": [ + { + "display_name": "X", + "source": "native", + "field_ref": [ + "field", + "X", + { + "base-type": "type/Date" + } + ], + "name": "X", + "base_type": "type/Date", + "effective_type": "type/Date" + }, + { + "display_name": "Y", + "source": "native", + "field_ref": [ + "field", + "Y", + { + "base-type": "type/Integer" + } + ], + "name": "Y", + "base_type": "type/Integer", + "effective_type": "type/Integer" + } + ], + "native_form": { + "params": null, + "query": "SELECT DATE '2020-01-01' AS x, 10 AS y\nUNION ALL\nSELECT DATE '2023-01-01', 10\nUNION ALL\nSELECT DATE '2024-01-01', 10" + }, + "format-rows?": true, + "results_timezone": "America/Montevideo", + "requested_timezone": "Canada/Eastern", + "results_metadata": { + "columns": [ + { + "display_name": "X", + "field_ref": [ + "field", + "X", + { + "base-type": "type/Date" + } + ], + "name": "X", + "base_type": "type/Date", + "effective_type": "type/Date", + "semantic_type": null, + "fingerprint": { + "global": { + "distinct-count": 3, + "nil%": 0 + }, + "type": { + "type/DateTime": { + "earliest": "2020-01-01T00:00:00-03:00", + "latest": "2024-01-01T00:00:00-03:00" + } + } + } + }, + { + "display_name": "Y", + "field_ref": [ + "field", + "Y", + { + "base-type": "type/Integer" + } + ], + "name": "Y", + "base_type": "type/Integer", + "effective_type": "type/Integer", + "semantic_type": null, + "fingerprint": { + "global": { + "distinct-count": 1, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 10, + "q1": 10, + "q3": 10, + "max": 10, + "sd": 0, + "avg": 10 + } + } + } + } + ] + }, + "insights": [ + { + "previous-value": 10, + "unit": "year", + "offset": 10, + "last-change": 0, + "col": "Y", + "slope": 0, + "last-value": 10, + "best-fit": ["+", 10, ["*", 0, ["log", "x"]]] + } + ] + } + } +] diff --git a/frontend/src/metabase/visualizations/echarts/cartesian/model/dataset.ts b/frontend/src/metabase/visualizations/echarts/cartesian/model/dataset.ts index aed25283ff65f71e810464e2638e60cd3ab8996b..578d7ff83a93f7ecee7496976daa70352afa7987 100644 --- a/frontend/src/metabase/visualizations/echarts/cartesian/model/dataset.ts +++ b/frontend/src/metabase/visualizations/echarts/cartesian/model/dataset.ts @@ -1,6 +1,7 @@ import { t } from "ttag"; import { getObjectKeys, getObjectValues } from "metabase/lib/objects"; +import { parseTimestamp } from "metabase/lib/time-dayjs"; import { checkNumber, isNotNull } from "metabase/lib/types"; import { isEmpty } from "metabase/lib/validate"; import { @@ -21,6 +22,7 @@ import type { XAxisModel, NumericAxisScaleTransforms, ShowWarning, + TimeSeriesXAxisModel, } from "metabase/visualizations/echarts/cartesian/model/types"; import type { CartesianChartColumns } from "metabase/visualizations/lib/graph/columns"; import { getNumberOr } from "metabase/visualizations/lib/settings/row-values"; @@ -282,12 +284,13 @@ export const getNullReplacerTransform = ( ) .map(seriesModel => seriesModel.dataKey); - return getKeyBasedDatasetTransform( - replaceNullsWithZeroDataKeys, - (value: RowValue) => { - return value === null ? 0 : value; - }, - ); + return datum => { + const transformedDatum = { ...datum }; + for (const key of replaceNullsWithZeroDataKeys) { + transformedDatum[key] = datum[key] != null ? datum[key] : 0; + } + return transformedDatum; + }; }; const hasInterpolatedSeries = ( @@ -518,6 +521,43 @@ function getHistogramDataset( return dataset; } +const MAX_FILL_COUNT = 10000; + +const interpolateTimeSeriesData = ( + dataset: ChartDataset, + axisModel: TimeSeriesXAxisModel, +): ChartDataset => { + if (axisModel.intervalsCount > MAX_FILL_COUNT) { + return dataset; + } + + const { count, unit } = axisModel.interval; + const result = []; + + for (let i = 0; i < dataset.length; i++) { + const datum = dataset[i]; + result.push(datum); + + if (i === dataset.length - 1) { + break; + } + + const end = parseTimestamp(dataset[i + 1][X_AXIS_DATA_KEY]); + + let start = parseTimestamp(datum[X_AXIS_DATA_KEY]); + while (start.add(count, unit).isBefore(end)) { + const interpolatedValue = start.add(count, unit); + result.push({ + [X_AXIS_DATA_KEY]: interpolatedValue.toISOString(), + }); + + start = interpolatedValue; + } + } + + return result; +}; + /** * Modifies the dataset for visualization according to the specified visualization settings. * @@ -539,8 +579,8 @@ export const applyVisualizationSettingsDataTransformations = ( const seriesDataKeys = seriesModels.map(seriesModel => seriesModel.dataKey); if ( - xAxisModel.axisType === "value" || - xAxisModel.axisType === "time" || + isNumericAxis(xAxisModel) || + isTimeSeriesAxis(xAxisModel) || xAxisModel.isHistogram ) { dataset = filterNullDimensionValues(dataset, showWarning); @@ -550,10 +590,14 @@ export const applyVisualizationSettingsDataTransformations = ( dataset = replaceZeroesForLogScale(dataset, seriesDataKeys); } - if (xAxisModel.axisType === "category" && xAxisModel.isHistogram) { + if (isCategoryAxis(xAxisModel) && xAxisModel.isHistogram) { dataset = getHistogramDataset(dataset, xAxisModel.histogramInterval); } + if (isTimeSeriesAxis(xAxisModel)) { + dataset = interpolateTimeSeriesData(dataset, xAxisModel); + } + return transformDataset(dataset, [ getNullReplacerTransform(settings, seriesModels), { diff --git a/frontend/src/metabase/visualizations/echarts/cartesian/model/dataset.unit.spec.ts b/frontend/src/metabase/visualizations/echarts/cartesian/model/dataset.unit.spec.ts index cefc17cc45c3adaa72720cf3fcf0d289672a8d7c..8e2b42768829a5e04af4148e4a6cfb3469e6cdd4 100644 --- a/frontend/src/metabase/visualizations/echarts/cartesian/model/dataset.unit.spec.ts +++ b/frontend/src/metabase/visualizations/echarts/cartesian/model/dataset.unit.spec.ts @@ -1,6 +1,9 @@ +import dayjs from "dayjs"; + import { createMockSeriesModel } from "__support__/echarts"; import { checkNumber } from "metabase/lib/types"; import { + ORIGINAL_INDEX_DATA_KEY, POSITIVE_STACK_TOTAL_DATA_KEY, X_AXIS_DATA_KEY, } from "metabase/visualizations/echarts/cartesian/constants/dataset"; @@ -34,6 +37,7 @@ import type { ChartDataset, LegacySeriesSettingsObjectKey, NumericAxisScaleTransforms, + TimeSeriesXAxisModel, XAxisModel, } from "./types"; @@ -400,6 +404,82 @@ describe("dataset transform functions", () => { ]); }); + describe("time series", () => { + const dataset = [ + { + [X_AXIS_DATA_KEY]: "2020-01-01T00:00:00.000Z", + dimensionKey: "A", + series1: 10, + }, + // Missing February + { + [X_AXIS_DATA_KEY]: "2020-03-01T00:00:00.000Z", + dimensionKey: "A", + series1: 20, + }, + ]; + + const xAxisModel: TimeSeriesXAxisModel = { + axisType: "time", + intervalsCount: 2, + interval: { + count: 1, + unit: "month", + }, + timezone: "UTC", + range: [dayjs(), dayjs()], + formatter: value => String(value), + fromEChartsAxisValue: () => dayjs(), + toEChartsAxisValue: val => String(val), + }; + + it("should replace missing values with zeros based on the x-axis interval", () => { + const result = applyVisualizationSettingsDataTransformations( + dataset, + xAxisModel, + [createMockSeriesModel({ dataKey: "series1" })], + yAxisScaleTransforms, + createMockComputedVisualizationSettings({ + series: () => ({ + "line.missing": "zero", + }), + }), + ); + + expect(result).toEqual([ + { + [ORIGINAL_INDEX_DATA_KEY]: 0, + [X_AXIS_DATA_KEY]: "2020-01-01T00:00:00.000Z", + dimensionKey: "A", + series1: 10, + }, + { [X_AXIS_DATA_KEY]: "2020-02-01T00:00:00.000Z", series1: 0 }, + { + [ORIGINAL_INDEX_DATA_KEY]: 1, + [X_AXIS_DATA_KEY]: "2020-03-01T00:00:00.000Z", + dimensionKey: "A", + series1: 20, + }, + ]); + }); + + it("should not replace missing values with zeros when x-axis interval is too big", () => { + const result = applyVisualizationSettingsDataTransformations( + dataset, + { ...xAxisModel, intervalsCount: 10001 }, + [createMockSeriesModel({ dataKey: "series1" })], + yAxisScaleTransforms, + createMockComputedVisualizationSettings({ + series: () => ({ + "line.missing": "zero", + }), + }), + ); + + expect(result).toHaveLength(dataset.length); + }); + }); + it("should work on empty datasets", () => { const result = applyVisualizationSettingsDataTransformations( [],