From dbdef76e639d3f5857f8e787d5435530f0ebaac7 Mon Sep 17 00:00:00 2001 From: Anton Kulyk <kuliks.anton@gmail.com> Date: Wed, 23 Oct 2024 16:58:07 +0100 Subject: [PATCH] Allow grouping bar chart series into "Other" category (#48265) * Add `graph.max_categories` viz setting * Allow grouping series into "other" category * Add tooltip * Toggle "other" series visibility * WIP: new implementation for `graph.series_order` * Remove not used function * Make "other" series a regular `SeriesModel` * Fix stacked bar charts * Remove colors from "other" tooltip * Sort "other" tooltip values * Update `graph.max_categories` setting - negative number validation - 0 value turns off grouping * Fix Loki test * Add basic loki test * Add loki test for stacked chart * Add loki test for stacked normalized chart * slight refactor of series_order setting * Add screenshots * Handle different kinds of aggregations for "other" * Handle different aggregation kinds in tooltips * Add `graph.other_series_aggregation_fn` viz setting * Fix type errors * Fix unit tests * settings ui * remove sorting by value to match series order setting, other color * hide grouped series controls * Add e2e test * Update screenshots * Fix incorrect series grouping for stacked charts * group series into other settings * Update e2e test to work with new viz settings * Disable drills for the "Other" series * fix max_categories setting popover positioning * fix total row has misaligned columns on other series values * Remove redundant `click({ force: true })` * Rename aggregation fn viz setting * Move aggregation fn setting to the popover * WIP * Update popover * Fix `it.only` * Fix legend sync issue * Disable "Other" category by default (temporary) * Enable aggregation fn picker for MBQL queries (temporary) * Move "Other" category summary to a tooltip row * Add `graph.max_categories_enabled` to viz settings type * Reuse already computed "Other" value in tooltips * Add null check * Fix series length check --------- Co-authored-by: Aleksandr Lesnenko <alxnddr@gmail.com> --- ..._ComboChart_Bar_Max_Categories_Default.png | Bin 0 -> 18533 bytes ..._ComboChart_Bar_Max_Categories_Stacked.png | Bin 0 -> 14323 bytes ..._Bar_Max_Categories_Stacked_Normalized.png | Bin 0 -> 18349 bytes .../helpers/e2e-visual-tests-helpers.js | 4 + .../bar_chart.cy.spec.js | 214 +++++- frontend/src/metabase-types/api/card.ts | 9 + frontend/src/metabase-types/api/dataset.ts | 15 + .../core/components/Sortable/SortableList.tsx | 28 +- .../ComboChart/ComboChart.stories.tsx | 27 + .../bar-histogram-series-breakout.json | 3 +- .../bar-max-categories-default.json | 612 ++++++++++++++++++ ...bar-max-categories-stacked-normalized.json | 606 +++++++++++++++++ .../bar-max-categories-stacked.json | 606 +++++++++++++++++ .../ComboChart/stories-data/index.ts | 6 + .../EChartsTooltip/EChartsTooltip.tsx | 12 +- .../ClickActions/ClickActionsView.tsx | 2 +- ...tNestedSettingsSeriesMultiple.unit.spec.js | 5 +- .../ChartSettingColorPicker.tsx | 2 +- .../settings/ChartSettingColorsPicker.jsx | 3 + .../settings/ChartSettingFieldPicker.jsx | 34 +- .../ChartSettingFieldPicker.unit.spec.js | 5 +- .../settings/ChartSettingFieldsPicker.jsx | 2 + .../settings/ChartSettingInputNumeric.tsx | 6 +- .../settings/ChartSettingMaxCategories.tsx | 85 +++ .../ChartSettingOrderedItems.tsx | 11 +- .../settings/ChartSettingSeriesOrder.tsx | 100 ++- .../components/settings/types.ts | 7 + .../echarts/cartesian/constants/dataset.ts | 3 + .../echarts/cartesian/model/dataset.ts | 30 +- .../cartesian/model/dataset.unit.spec.ts | 8 + .../echarts/cartesian/model/guards.ts | 4 +- .../echarts/cartesian/model/index.ts | 34 +- .../echarts/cartesian/model/legend.ts | 4 +- .../echarts/cartesian/model/other-series.ts | 154 +++++ .../echarts/cartesian/model/series.ts | 2 - .../echarts/cartesian/model/types.ts | 3 + .../echarts/cartesian/option/index.ts | 2 + .../echarts/cartesian/scatter/model/index.ts | 1 + .../visualizations/lib/settings/graph.js | 105 ++- .../visualizations/lib/settings/series.js | 5 + .../shared/settings/cartesian-chart.ts | 9 + .../visualizations/CartesianChart/events.ts | 149 +++-- 42 files changed, 2803 insertions(+), 114 deletions(-) create mode 100644 .loki/reference/chrome_laptop_static_viz_ComboChart_Bar_Max_Categories_Default.png create mode 100644 .loki/reference/chrome_laptop_static_viz_ComboChart_Bar_Max_Categories_Stacked.png create mode 100644 .loki/reference/chrome_laptop_static_viz_ComboChart_Bar_Max_Categories_Stacked_Normalized.png create mode 100644 frontend/src/metabase/static-viz/components/ComboChart/stories-data/bar-max-categories-default.json create mode 100644 frontend/src/metabase/static-viz/components/ComboChart/stories-data/bar-max-categories-stacked-normalized.json create mode 100644 frontend/src/metabase/static-viz/components/ComboChart/stories-data/bar-max-categories-stacked.json create mode 100644 frontend/src/metabase/visualizations/components/settings/ChartSettingMaxCategories.tsx create mode 100644 frontend/src/metabase/visualizations/components/settings/types.ts create mode 100644 frontend/src/metabase/visualizations/echarts/cartesian/model/other-series.ts diff --git a/.loki/reference/chrome_laptop_static_viz_ComboChart_Bar_Max_Categories_Default.png b/.loki/reference/chrome_laptop_static_viz_ComboChart_Bar_Max_Categories_Default.png new file mode 100644 index 0000000000000000000000000000000000000000..847a02a9ae74b2a6dc84a41dcd44af59905408f6 GIT binary patch literal 18533 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 literal 0 HcmV?d00001 diff --git a/.loki/reference/chrome_laptop_static_viz_ComboChart_Bar_Max_Categories_Stacked.png b/.loki/reference/chrome_laptop_static_viz_ComboChart_Bar_Max_Categories_Stacked.png new file mode 100644 index 0000000000000000000000000000000000000000..021f1fb51b320c4013011f6aa1666e9342989ff4 GIT binary patch literal 14323 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 Bh%W#D literal 0 HcmV?d00001 diff --git a/.loki/reference/chrome_laptop_static_viz_ComboChart_Bar_Max_Categories_Stacked_Normalized.png b/.loki/reference/chrome_laptop_static_viz_ComboChart_Bar_Max_Categories_Stacked_Normalized.png new file mode 100644 index 0000000000000000000000000000000000000000..a411f14425ef4e609e98c8c69d9d26eeabeaa5ff GIT binary patch literal 18349 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 G>3;$B`Y(k5 literal 0 HcmV?d00001 diff --git a/e2e/support/helpers/e2e-visual-tests-helpers.js b/e2e/support/helpers/e2e-visual-tests-helpers.js index 916f0f475ac..270f46e3623 100644 --- a/e2e/support/helpers/e2e-visual-tests-helpers.js +++ b/e2e/support/helpers/e2e-visual-tests-helpers.js @@ -81,6 +81,10 @@ export function cartesianChartCircleWithColors(colors) { return colors.map(color => cartesianChartCircleWithColor(color)); } +export function otherSeriesChartPaths() { + return chartPathWithFillColor("#949AAB"); +} + export function scatterBubbleWithColor(color) { return echartsContainer().find(`path[d="${CIRCLE_PATH}"][fill="${color}"]`); } diff --git a/e2e/test/scenarios/visualizations-charts/bar_chart.cy.spec.js b/e2e/test/scenarios/visualizations-charts/bar_chart.cy.spec.js index ba06be055aa..baa5338c876 100644 --- a/e2e/test/scenarios/visualizations-charts/bar_chart.cy.spec.js +++ b/e2e/test/scenarios/visualizations-charts/bar_chart.cy.spec.js @@ -8,13 +8,17 @@ import { createQuestion, cypressWaitAll, echartsContainer, + echartsTooltip, getDraggableElements, getValueLabels, leftSidebar, modal, moveDnDKitElement, + openNotebook, + otherSeriesChartPaths, popover, queryBuilderHeader, + queryBuilderMain, restore, sidebar, visitDashboard, @@ -368,16 +372,18 @@ describe("scenarios > visualizations > bar chart", () => { }); cy.findByTestId("viz-settings-button").click(); + leftSidebar().button("90 more series").click(); cy.get("[data-testid^=draggable-item]").should("have.length", 100); - // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage - cy.findByText("ID is less than 101").click(); - cy.findByDisplayValue("101").type("{backspace}2"); - // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage - cy.findByText("Update filter").click(); + cy.findByTestId("qb-filters-panel") + .findByText("ID is less than 101") + .click(); + popover().within(() => { + cy.findByDisplayValue("101").type("{backspace}2"); + cy.button("Update filter").click(); + }); - // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage - cy.findByText( + queryBuilderMain().findByText( "This chart type doesn't support more than 100 series of data.", ); cy.get("[data-testid^=draggable-item]").should("have.length", 0); @@ -752,6 +758,200 @@ describe("scenarios > visualizations > bar chart", () => { }); resetHoverState(); }); + + it("should allow grouping series into a single 'Other' series", () => { + const AK_SERIES_COLOR = "#509EE3"; + + const USER_STATE_FIELD_REF = [ + "field", + PEOPLE.STATE, + { "source-field": ORDERS.USER_ID }, + ]; + const ORDER_CREATED_AT_FIELD_REF = [ + "field", + ORDERS.CREATED_AT, + { "temporal-unit": "month" }, + ]; + + function setMaxCategories(value, { viaBreakoutSettings = false } = {}) { + if (viaBreakoutSettings) { + leftSidebar().findByTestId("settings-STATE").click(); + } else { + leftSidebar().findByLabelText("Other series settings").click(); + } + popover() + .findByTestId("graph-max-categories-input") + .type(`{selectAll}${value}`) + .blur(); + cy.wait(500); // wait for viz to re-render + } + + function setOtherCategoryAggregationFn(fnName) { + leftSidebar().findByLabelText("Other series settings").click(); + popover() + .findByTestId("graph-other-category-aggregation-fn-picker") + .click(); + popover().last().findByText(fnName).click(); + } + + visitQuestionAdhoc({ + display: "bar", + dataset_query: { + type: "query", + database: SAMPLE_DB_ID, + query: { + "source-table": ORDERS_ID, + aggregation: [["count"]], + breakout: [USER_STATE_FIELD_REF, ORDER_CREATED_AT_FIELD_REF], + filter: [ + "and", + [ + "between", + ORDER_CREATED_AT_FIELD_REF, + "2022-09-01T00:00Z", + "2023-02-01T00:00Z", + ], + [ + "=", + USER_STATE_FIELD_REF, + "AK", + "AL", + "AR", + "AZ", + "CA", + "CO", + "CT", + "DE", + "FL", + "GA", + "IA", + "ID", + "IL", + "KY", + ], + ], + }, + }, + }); + + // Enable 'Other' series + cy.findByTestId("viz-settings-button").click(); + leftSidebar().findByTestId("settings-STATE").click(); + popover().findByLabelText("Enforce maximum number of series").click(); + + // Test 'Other' series renders + otherSeriesChartPaths().should("have.length", 6); + + // Test drill-through is disabled for 'Other' series + otherSeriesChartPaths().first().click(); + cy.findByTestId("click-actions-view").should("not.exist"); + + // Test drill-through is enabled for regular series + chartPathWithFillColor(AK_SERIES_COLOR).first().click(); + cy.findByTestId("click-actions-view").should("exist"); + + // Test legend and series visibility toggling + queryBuilderMain() + .findAllByTestId("legend-item") + .should("have.length", 9) + .last() + .as("other-series-legend-item"); + cy.get("@other-series-legend-item").findByLabelText("Hide series").click(); + otherSeriesChartPaths().should("have.length", 0); + cy.get("@other-series-legend-item").findByLabelText("Show series").click(); + otherSeriesChartPaths().should("have.length", 6); + + // Test tooltips + chartPathWithFillColor(AK_SERIES_COLOR).first().realHover(); + assertEChartsTooltip({ rows: [{ name: "Other", value: "9" }] }); + otherSeriesChartPaths().first().realHover(); + assertEChartsTooltip({ + header: "September 2022", + rows: [ + { name: "IA", value: "3" }, + { name: "KY", value: "2" }, + { name: "FL", value: "1" }, + { name: "GA", value: "1" }, + { name: "ID", value: "1" }, + { name: "IL", value: "1" }, + { name: "Total", value: "9" }, + ], + }); + + // Test "graph.max_categories" change + setMaxCategories(4); + queryBuilderMain().click(); // close popover + chartPathWithFillColor(AK_SERIES_COLOR).first().realHover(); + echartsTooltip().find("tr").should("have.length", 5); + queryBuilderMain().findAllByTestId("legend-item").should("have.length", 5); + + // Test can move series in/out of "Other" series + moveDnDKitElement(getDraggableElements().eq(3), { vertical: 150 }); // Move AZ into "Other" + moveDnDKitElement(getDraggableElements().eq(6), { vertical: -150 }); // Move CT out of "Other" + + queryBuilderMain().findAllByTestId("legend-item").should("have.length", 5); + queryBuilderMain() + .findAllByTestId("legend-item") + .contains("AZ") + .should("not.exist"); + queryBuilderMain() + .findAllByTestId("legend-item") + .contains("CT") + .should("exist"); + + // Test "graph.max_categories" removes "Other" altogether + setMaxCategories(0); + chartPathWithFillColor(AK_SERIES_COLOR).first().realHover(); + echartsTooltip().find("tr").should("have.length", 14); + queryBuilderMain().findAllByTestId("legend-item").should("have.length", 14); + otherSeriesChartPaths().should("not.exist"); + setMaxCategories(8, { viaBreakoutSettings: true }); + + // Test "graph.other_category_aggregation_fn" for native queries + openNotebook(); + queryBuilderHeader().button("View the SQL").click(); + cy.findByTestId("native-query-preview-sidebar") + .button("Convert this question to SQL") + .click(); + cy.wait("@dataset"); + queryBuilderMain().findByTestId("visibility-toggler").click(); + + cy.findByTestId("viz-settings-button").click(); + setOtherCategoryAggregationFn("Average"); + + chartPathWithFillColor(AK_SERIES_COLOR).first().realHover(); + assertEChartsTooltip({ rows: [{ name: "Other", value: "1.5" }] }); + + otherSeriesChartPaths().first().realHover(); + assertEChartsTooltip({ + header: "September 2022", + rows: [ + { name: "IA", value: "3" }, + { name: "KY", value: "2" }, + { name: "FL", value: "1" }, + { name: "GA", value: "1" }, + { name: "ID", value: "1" }, + { name: "IL", value: "1" }, + { name: "Average", value: "1.5" }, + ], + }); + + setOtherCategoryAggregationFn("Min"); + + chartPathWithFillColor(AK_SERIES_COLOR).first().realHover(); + assertEChartsTooltip({ rows: [{ name: "Other", value: "1" }] }); + + otherSeriesChartPaths().first().realHover(); + assertEChartsTooltip({ rows: [{ name: "Min", value: "1" }] }); + + setOtherCategoryAggregationFn("Max"); + + chartPathWithFillColor(AK_SERIES_COLOR).first().realHover(); + assertEChartsTooltip({ rows: [{ name: "Other", value: "3" }] }); + + otherSeriesChartPaths().first().realHover(); + assertEChartsTooltip({ rows: [{ name: "Max", value: "3" }] }); + }); }); function resetHoverState() { diff --git a/frontend/src/metabase-types/api/card.ts b/frontend/src/metabase-types/api/card.ts index b2e07251539..babfb5c17ef 100644 --- a/frontend/src/metabase-types/api/card.ts +++ b/frontend/src/metabase-types/api/card.ts @@ -151,6 +151,15 @@ export type VisualizationSettings = { "graph.show_values"?: boolean; "stackable.stack_type"?: StackType; "graph.show_stack_values"?: StackValuesDisplay; + "graph.max_categories_enabled"?: boolean; + "graph.max_categories"?: number; + "graph.other_category_aggregation_fn"?: + | "sum" + | "avg" + | "min" + | "max" + | "stddev" + | "median"; // Table "table.columns"?: TableColumnOrderSetting[]; diff --git a/frontend/src/metabase-types/api/dataset.ts b/frontend/src/metabase-types/api/dataset.ts index 51e7801e792..a6125b49b1e 100644 --- a/frontend/src/metabase-types/api/dataset.ts +++ b/frontend/src/metabase-types/api/dataset.ts @@ -18,6 +18,18 @@ export type BinningMetadata = { num_bins?: number; }; +export type AggregationType = + | "count" + | "sum" + | "cum-sum" + | "cum-count" + | "distinct" + | "min" + | "max" + | "avg" + | "median" + | "stddev"; + export interface DatasetColumn { id?: FieldId; name: string; @@ -25,6 +37,9 @@ export interface DatasetColumn { description?: string | null; source: string; aggregation_index?: number; + + aggregation_type?: AggregationType; + coercion_strategy?: string | null; visibility_type?: FieldVisibilityType; table_id?: TableId; diff --git a/frontend/src/metabase/core/components/Sortable/SortableList.tsx b/frontend/src/metabase/core/components/Sortable/SortableList.tsx index eb69a9e1416..91c72702d14 100644 --- a/frontend/src/metabase/core/components/Sortable/SortableList.tsx +++ b/frontend/src/metabase/core/components/Sortable/SortableList.tsx @@ -6,12 +6,17 @@ import type { } from "@dnd-kit/core"; import { DndContext, DragOverlay } from "@dnd-kit/core"; import { SortableContext, arrayMove } from "@dnd-kit/sortable"; -import { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import _ from "underscore"; import GrabberS from "metabase/css/components/grabber.module.css"; import { isNotNull } from "metabase/lib/types"; +export type SortableDivider = { + afterIndex: number; + renderFn: () => React.ReactNode; +}; + type ItemId = number | string; export type DragEndEvent = { id: ItemId; @@ -37,6 +42,7 @@ type SortableListProps<T> = { sensors?: SensorDescriptor<any>[]; modifiers?: Modifier[]; useDragOverlay?: boolean; + dividers?: SortableDivider[]; }; export const SortableList = <T,>({ @@ -48,6 +54,7 @@ export const SortableList = <T,>({ sensors = [], modifiers = [], useDragOverlay = true, + dividers, }: SortableListProps<T>) => { const [itemIds, setItemIds] = useState<ItemId[]>([]); const [indexedItems, setIndexedItems] = useState<Partial<Record<ItemId, T>>>( @@ -55,6 +62,13 @@ export const SortableList = <T,>({ ); const [activeItem, setActiveItem] = useState<T | null>(null); + const dividersByIndex = useMemo(() => { + return (dividers ?? []).reduce((acc, item) => { + acc.set(item.afterIndex, item); + return acc; + }, new Map<number, SortableDivider>()); + }, [dividers]); + useEffect(() => { setItemIds(items.map(getId)); setIndexedItems(_.indexBy(items, getId)); @@ -63,14 +77,20 @@ export const SortableList = <T,>({ const sortableElements = useMemo( () => itemIds - .map(id => { + .map((id, index) => { const item = indexedItems[id]; + const divider = dividersByIndex.get(index); if (item) { - return renderItem({ item, id }); + return ( + <React.Fragment key={id}> + {divider ? divider.renderFn() : null} + {renderItem({ item, id })} + </React.Fragment> + ); } }) .filter(isNotNull), - [itemIds, renderItem, indexedItems], + [itemIds, indexedItems, dividersByIndex, renderItem], ); const handleDragOver = ({ active, over }: DragOverEvent) => { 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 f99d70299bd..80f820e9668 100644 --- a/frontend/src/metabase/static-viz/components/ComboChart/ComboChart.stories.tsx +++ b/frontend/src/metabase/static-viz/components/ComboChart/ComboChart.stories.tsx @@ -973,6 +973,33 @@ export const BarStackedAllLabelsTimeseriesWithGap45717 = { }, }; +export const BarMaxCategoriesDefault = { + render: Template, + + args: { + rawSeries: data.barMaxCategoriesDefault as any, + renderingContext, + }, +}; + +export const BarMaxCategoriesStacked = { + render: Template, + + args: { + rawSeries: data.barMaxCategoriesStacked as any, + renderingContext, + }, +}; + +export const BarMaxCategoriesStackedNormalized = { + render: Template, + + args: { + rawSeries: data.barMaxCategoriesStackedNormalized as any, + renderingContext, + }, +}; + export const OffsetBasedTimezone47835 = { render: Template, args: { diff --git a/frontend/src/metabase/static-viz/components/ComboChart/stories-data/bar-histogram-series-breakout.json b/frontend/src/metabase/static-viz/components/ComboChart/stories-data/bar-histogram-series-breakout.json index 733b3873499..d771e36ea13 100644 --- a/frontend/src/metabase/static-viz/components/ComboChart/stories-data/bar-histogram-series-breakout.json +++ b/frontend/src/metabase/static-viz/components/ComboChart/stories-data/bar-histogram-series-breakout.json @@ -28,7 +28,8 @@ "graph.series_order": null, "graph.x_axis.scale": "histogram", "stackable.stack_type": null, - "graph.metrics": ["count"] + "graph.metrics": ["count"], + "graph.max_categories": 0 }, "last-edit-info": { "id": 1, diff --git a/frontend/src/metabase/static-viz/components/ComboChart/stories-data/bar-max-categories-default.json b/frontend/src/metabase/static-viz/components/ComboChart/stories-data/bar-max-categories-default.json new file mode 100644 index 00000000000..fe50cdadb55 --- /dev/null +++ b/frontend/src/metabase/static-viz/components/ComboChart/stories-data/bar-max-categories-default.json @@ -0,0 +1,612 @@ +[ + { + "card": { + "cache_invalidated_at": null, + "description": null, + "archived": false, + "view_count": 150, + "collection_position": null, + "source_card_id": null, + "table_id": 5, + "can_run_adhoc_query": true, + "result_metadata": [ + { + "description": "The state or province of the account’s billing address", + "database_type": "CHARACTER", + "semantic_type": "type/State", + "table_id": 3, + "coercion_strategy": null, + "name": "STATE", + "settings": null, + "source": "breakout", + "fk_target_field_id": null, + "fk_field_id": 43, + "field_ref": [ + "field", + 48, + { + "base-type": "type/Text", + "source-field": 43 + } + ], + "effective_type": "type/Text", + "nfc_path": null, + "parent_id": null, + "id": 48, + "position": 7, + "visibility_type": "normal", + "display_name": "User → State", + "fingerprint": { + "global": { + "distinct-count": 49, + "nil%": 0.0 + }, + "type": { + "type/Text": { + "percent-json": 0.0, + "percent-url": 0.0, + "percent-email": 0.0, + "percent-state": 1.0, + "average-length": 2.0 + } + } + }, + "base_type": "type/Text", + "source_alias": "PEOPLE__via__USER_ID" + }, + { + "description": "The date and time an order was submitted.", + "database_type": "TIMESTAMP", + "semantic_type": "type/CreationTimestamp", + "table_id": 5, + "coercion_strategy": null, + "unit": "month", + "name": "CREATED_AT", + "settings": null, + "source": "breakout", + "fk_target_field_id": null, + "field_ref": [ + "field", + 41, + { + "base-type": "type/DateTime", + "temporal-unit": "month" + } + ], + "effective_type": "type/DateTime", + "nfc_path": null, + "parent_id": null, + "id": 41, + "position": 7, + "visibility_type": "normal", + "display_name": "Created At", + "fingerprint": { + "global": { + "distinct-count": 10001, + "nil%": 0.0 + }, + "type": { + "type/DateTime": { + "earliest": "2022-04-30T18:56:13.352Z", + "latest": "2026-04-19T14:07:15.657Z" + } + } + }, + "base_type": "type/DateTime" + }, + { + "base_type": "type/Integer", + "name": "count", + "display_name": "Count", + "semantic_type": "type/Quantity", + "source": "aggregation", + "field_ref": ["aggregation", 0], + "aggregation_index": 0 + } + ], + "creator": { + "email": "anton@metabase.test", + "first_name": "Anton", + "last_login": "2024-09-24T15:34:26.000532+01:00", + "is_qbnewb": false, + "is_superuser": true, + "id": 1, + "last_name": "Kulyk", + "date_joined": "2024-08-19T15:09:37.030585+01:00", + "common_name": "Anton Kulyk" + }, + "initially_published_at": null, + "can_write": true, + "database_id": 1, + "enable_embedding": false, + "collection_id": null, + "query_type": "query", + "name": "Bar chart with \"Other\"", + "last_query_start": "2024-10-03T14:40:03.849841+01:00", + "dashboard_count": 1, + "last_used_at": "2024-10-03T14:40:03.908296+01:00", + "type": "question", + "average_query_time": 71.93137254901961, + "creator_id": 1, + "can_restore": false, + "moderation_reviews": [], + "updated_at": "2024-10-04T14:53:50.393173+01:00", + "made_public_by_id": null, + "embedding_params": null, + "cache_ttl": null, + "dataset_query": { + "database": 1, + "type": "query", + "query": { + "source-table": 5, + "aggregation": [["count"]], + "breakout": [ + [ + "field", + 48, + { + "base-type": "type/Text", + "source-field": 43 + } + ], + [ + "field", + 41, + { + "base-type": "type/DateTime", + "temporal-unit": "month" + } + ] + ], + "filter": [ + "and", + [ + "between", + [ + "field", + 41, + { + "base-type": "type/DateTime", + "temporal-unit": "month" + } + ], + "2022-09-01T00:00Z", + "2023-02-01T00:00Z" + ], + [ + "=", + [ + "field", + 48, + { + "base-type": "type/Text", + "source-field": 43 + } + ], + "AK", + "AL", + "AR", + "AZ", + "CA", + "CO", + "CT", + "DE", + "FL", + "GA", + "IA", + "ID", + "IL", + "KY" + ] + ] + } + }, + "id": 47, + "parameter_mappings": [], + "display": "bar", + "archived_directly": false, + "entity_id": "ez2Yb1JhGrltIJMNwZfwU", + "collection_preview": true, + "last-edit-info": { + "id": 1, + "email": "anton@metabase.test", + "first_name": "Anton", + "last_name": "Kulyk", + "timestamp": "2024-10-04T14:53:50.464787+01:00" + }, + "visualization_settings": { + "graph.max_categories_enabled": true, + "graph.max_categories": 8, + "graph.dimensions": ["CREATED_AT", "STATE"], + "graph.series_order": [ + { + "key": "AK", + "color": "#509EE3", + "enabled": true, + "name": "AK" + }, + { + "key": "AL", + "color": "#227FD2", + "enabled": true, + "name": "AL" + }, + { + "key": "AR", + "color": "#88BF4D", + "enabled": true, + "name": "AR" + }, + { + "key": "AZ", + "color": "#689636", + "enabled": true, + "name": "AZ" + }, + { + "key": "CA", + "color": "#A989C5", + "enabled": true, + "name": "CA" + }, + { + "key": "CO", + "color": "#8A5EB0", + "enabled": true, + "name": "CO" + }, + { + "key": "CT", + "color": "#EF8C8C", + "enabled": true, + "name": "CT" + }, + { + "key": "DE", + "color": "#E75454", + "enabled": true, + "name": "DE" + }, + { + "key": "GA", + "color": "#F9D45C", + "enabled": true, + "name": "GA" + }, + { + "key": "IA", + "color": "#F7C41F", + "enabled": true, + "name": "IA" + }, + { + "key": "ID", + "color": "#F2A86F", + "enabled": true, + "name": "ID" + }, + { + "key": "KY", + "color": "#ED8535", + "enabled": true, + "name": "KY" + }, + { + "key": "LA", + "color": "#98D9D9", + "enabled": true, + "name": "LA" + } + ], + "graph.series_order_dimension": "STATE", + "stackable.stack_type": null, + "pie.dimension": ["STATE"], + "graph.metrics": ["count"] + }, + "collection": { + "metabase.models.collection.root/is-root?": true, + "authority_level": null, + "name": "Our analytics", + "is_personal": false, + "id": "root", + "can_write": true + }, + "metabase_version": "v0.1.37-SNAPSHOT (5b4a5d6)", + "parameters": [], + "created_at": "2024-10-01T13:37:28.812936+01:00", + "parameter_usage_count": 0, + "public_uuid": null, + "can_delete": false + }, + "data": { + "rows": [ + ["AK", "2022-09-01T00:00:00+01:00", 2], + ["AK", "2022-10-01T00:00:00+01:00", 3], + ["AK", "2022-11-01T00:00:00Z", 1], + ["AK", "2022-12-01T00:00:00Z", 3], + ["AK", "2023-01-01T00:00:00Z", 9], + ["AK", "2023-02-01T00:00:00Z", 4], + ["AL", "2022-09-01T00:00:00+01:00", 1], + ["AL", "2022-10-01T00:00:00+01:00", 3], + ["AL", "2022-11-01T00:00:00Z", 2], + ["AL", "2022-12-01T00:00:00Z", 6], + ["AL", "2023-01-01T00:00:00Z", 6], + ["AL", "2023-02-01T00:00:00Z", 6], + ["AR", "2022-10-01T00:00:00+01:00", 2], + ["AR", "2022-11-01T00:00:00Z", 4], + ["AR", "2022-12-01T00:00:00Z", 3], + ["AR", "2023-01-01T00:00:00Z", 4], + ["AR", "2023-02-01T00:00:00Z", 1], + ["AZ", "2023-01-01T00:00:00Z", 1], + ["AZ", "2023-02-01T00:00:00Z", 1], + ["CA", "2022-09-01T00:00:00+01:00", 5], + ["CA", "2022-10-01T00:00:00+01:00", 5], + ["CA", "2022-11-01T00:00:00Z", 4], + ["CA", "2022-12-01T00:00:00Z", 6], + ["CA", "2023-01-01T00:00:00Z", 11], + ["CA", "2023-02-01T00:00:00Z", 11], + ["CO", "2022-09-01T00:00:00+01:00", 4], + ["CO", "2022-10-01T00:00:00+01:00", 6], + ["CO", "2022-11-01T00:00:00Z", 12], + ["CO", "2022-12-01T00:00:00Z", 8], + ["CO", "2023-01-01T00:00:00Z", 7], + ["CO", "2023-02-01T00:00:00Z", 9], + ["CT", "2022-10-01T00:00:00+01:00", 1], + ["DE", "2022-10-01T00:00:00+01:00", 1], + ["DE", "2022-12-01T00:00:00Z", 1], + ["FL", "2022-09-01T00:00:00+01:00", 1], + ["FL", "2022-10-01T00:00:00+01:00", 2], + ["FL", "2022-11-01T00:00:00Z", 4], + ["FL", "2023-01-01T00:00:00Z", 3], + ["FL", "2023-02-01T00:00:00Z", 4], + ["GA", "2022-09-01T00:00:00+01:00", 1], + ["GA", "2022-10-01T00:00:00+01:00", 7], + ["GA", "2022-11-01T00:00:00Z", 3], + ["GA", "2022-12-01T00:00:00Z", 1], + ["GA", "2023-01-01T00:00:00Z", 8], + ["GA", "2023-02-01T00:00:00Z", 3], + ["IA", "2022-09-01T00:00:00+01:00", 3], + ["IA", "2022-10-01T00:00:00+01:00", 4], + ["IA", "2022-11-01T00:00:00Z", 5], + ["IA", "2022-12-01T00:00:00Z", 5], + ["IA", "2023-01-01T00:00:00Z", 10], + ["IA", "2023-02-01T00:00:00Z", 7], + ["ID", "2022-09-01T00:00:00+01:00", 1], + ["ID", "2022-10-01T00:00:00+01:00", 1], + ["ID", "2022-11-01T00:00:00Z", 1], + ["ID", "2022-12-01T00:00:00Z", 2], + ["ID", "2023-01-01T00:00:00Z", 3], + ["ID", "2023-02-01T00:00:00Z", 4], + ["IL", "2022-09-01T00:00:00+01:00", 1], + ["IL", "2022-10-01T00:00:00+01:00", 3], + ["IL", "2022-11-01T00:00:00Z", 3], + ["IL", "2022-12-01T00:00:00Z", 6], + ["IL", "2023-01-01T00:00:00Z", 5], + ["IL", "2023-02-01T00:00:00Z", 5], + ["KY", "2022-09-01T00:00:00+01:00", 2], + ["KY", "2022-10-01T00:00:00+01:00", 5], + ["KY", "2022-11-01T00:00:00Z", 5], + ["KY", "2022-12-01T00:00:00Z", 4], + ["KY", "2023-01-01T00:00:00Z", 4], + ["KY", "2023-02-01T00:00:00Z", 3] + ], + "cols": [ + { + "description": "The state or province of the account’s billing address", + "database_type": "CHARACTER", + "semantic_type": "type/State", + "table_id": 3, + "coercion_strategy": null, + "name": "STATE", + "settings": null, + "source": "breakout", + "fk_target_field_id": null, + "fk_field_id": 43, + "field_ref": [ + "field", + 48, + { + "base-type": "type/Text", + "source-field": 43 + } + ], + "effective_type": "type/Text", + "nfc_path": null, + "parent_id": null, + "id": 48, + "position": 7, + "visibility_type": "normal", + "display_name": "User → State", + "fingerprint": { + "global": { + "distinct-count": 49, + "nil%": 0.0 + }, + "type": { + "type/Text": { + "percent-json": 0.0, + "percent-url": 0.0, + "percent-email": 0.0, + "percent-state": 1.0, + "average-length": 2.0 + } + } + }, + "base_type": "type/Text", + "source_alias": "PEOPLE__via__USER_ID" + }, + { + "description": "The date and time an order was submitted.", + "database_type": "TIMESTAMP", + "semantic_type": "type/CreationTimestamp", + "table_id": 5, + "coercion_strategy": null, + "unit": "month", + "name": "CREATED_AT", + "settings": null, + "source": "breakout", + "fk_target_field_id": null, + "field_ref": [ + "field", + 41, + { + "base-type": "type/DateTime", + "temporal-unit": "month" + } + ], + "effective_type": "type/DateTime", + "nfc_path": null, + "parent_id": null, + "id": 41, + "position": 7, + "visibility_type": "normal", + "display_name": "Created At", + "fingerprint": { + "global": { + "distinct-count": 10001, + "nil%": 0.0 + }, + "type": { + "type/DateTime": { + "earliest": "2022-04-30T18:56:13.352Z", + "latest": "2026-04-19T14:07:15.657Z" + } + } + }, + "base_type": "type/DateTime" + }, + { + "database_type": "BIGINT", + "semantic_type": "type/Quantity", + "name": "count", + "source": "aggregation", + "field_ref": ["aggregation", 0], + "effective_type": "type/BigInteger", + "aggregation_index": 0, + "display_name": "Count", + "base_type": "type/BigInteger" + } + ], + "native_form": { + "query": "SELECT \"PEOPLE__via__USER_ID\".\"STATE\" AS \"PEOPLE__via__USER_ID__STATE\", DATE_TRUNC('month', \"PUBLIC\".\"ORDERS\".\"CREATED_AT\") AS \"CREATED_AT\", COUNT(*) AS \"count\" FROM \"PUBLIC\".\"ORDERS\" LEFT JOIN \"PUBLIC\".\"PEOPLE\" AS \"PEOPLE__via__USER_ID\" ON \"PUBLIC\".\"ORDERS\".\"USER_ID\" = \"PEOPLE__via__USER_ID\".\"ID\" WHERE (\"PUBLIC\".\"ORDERS\".\"CREATED_AT\" >= timestamp '2022-09-01 00:00:00.000') AND (\"PUBLIC\".\"ORDERS\".\"CREATED_AT\" < timestamp '2023-03-01 00:00:00.000') AND ((\"PEOPLE__via__USER_ID\".\"STATE\" = 'AK') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'AL') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'AR') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'AZ') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'CA') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'CO') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'CT') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'DE') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'FL') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'GA') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'IA') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'ID') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'IL') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'KY')) GROUP BY \"PEOPLE__via__USER_ID\".\"STATE\", DATE_TRUNC('month', \"PUBLIC\".\"ORDERS\".\"CREATED_AT\") ORDER BY \"PEOPLE__via__USER_ID\".\"STATE\" ASC, DATE_TRUNC('month', \"PUBLIC\".\"ORDERS\".\"CREATED_AT\") ASC", + "params": null + }, + "format-rows?": true, + "results_timezone": "Europe/Lisbon", + "results_metadata": { + "columns": [ + { + "description": "The state or province of the account’s billing address", + "semantic_type": "type/State", + "coercion_strategy": null, + "name": "STATE", + "settings": null, + "fk_target_field_id": null, + "field_ref": [ + "field", + 48, + { + "base-type": "type/Text", + "source-field": 43 + } + ], + "effective_type": "type/Text", + "id": 48, + "visibility_type": "normal", + "display_name": "User → State", + "fingerprint": { + "global": { + "distinct-count": 49, + "nil%": 0.0 + }, + "type": { + "type/Text": { + "percent-json": 0.0, + "percent-url": 0.0, + "percent-email": 0.0, + "percent-state": 1.0, + "average-length": 2.0 + } + } + }, + "base_type": "type/Text" + }, + { + "description": "The date and time an order was submitted.", + "semantic_type": "type/CreationTimestamp", + "coercion_strategy": null, + "unit": "month", + "name": "CREATED_AT", + "settings": null, + "fk_target_field_id": null, + "field_ref": [ + "field", + 41, + { + "base-type": "type/DateTime", + "temporal-unit": "month" + } + ], + "effective_type": "type/DateTime", + "id": 41, + "visibility_type": "normal", + "display_name": "Created At", + "fingerprint": { + "global": { + "distinct-count": 10001, + "nil%": 0.0 + }, + "type": { + "type/DateTime": { + "earliest": "2022-04-30T18:56:13.352Z", + "latest": "2026-04-19T14:07:15.657Z" + } + } + }, + "base_type": "type/DateTime" + }, + { + "display_name": "Count", + "semantic_type": "type/Quantity", + "field_ref": ["aggregation", 0], + "base_type": "type/BigInteger", + "effective_type": "type/BigInteger", + "name": "count", + "fingerprint": { + "global": { + "distinct-count": 12, + "nil%": 0.0 + }, + "type": { + "type/Number": { + "min": 1.0, + "q1": 1.8849307066960952, + "q3": 5.5, + "max": 12.0, + "sd": 2.737211604119948, + "avg": 4.086956521739131 + } + } + } + } + ] + }, + "insights": [ + { + "previous-value": 4, + "unit": "month", + "offset": -377.8969468095498, + "last-change": -0.25, + "col": "count", + "slope": 0.019777876708186145, + "last-value": 3, + "best-fit": [ + "*", + 4.201731368215701e-45, + ["exp", ["*", 0.005350764152580669, "x"]] + ] + } + ] + } + } +] diff --git a/frontend/src/metabase/static-viz/components/ComboChart/stories-data/bar-max-categories-stacked-normalized.json b/frontend/src/metabase/static-viz/components/ComboChart/stories-data/bar-max-categories-stacked-normalized.json new file mode 100644 index 00000000000..5c9bd15df42 --- /dev/null +++ b/frontend/src/metabase/static-viz/components/ComboChart/stories-data/bar-max-categories-stacked-normalized.json @@ -0,0 +1,606 @@ +[ + { + "card": { + "cache_invalidated_at": null, + "description": null, + "archived": false, + "view_count": 151, + "collection_position": null, + "source_card_id": null, + "table_id": 5, + "can_run_adhoc_query": true, + "result_metadata": [ + { + "description": "The state or province of the account’s billing address", + "semantic_type": "type/State", + "coercion_strategy": null, + "name": "STATE", + "settings": null, + "fk_target_field_id": null, + "field_ref": [ + "field", + 48, + { + "base-type": "type/Text", + "source-field": 43 + } + ], + "effective_type": "type/Text", + "id": 48, + "visibility_type": "normal", + "display_name": "User → State", + "fingerprint": { + "global": { + "distinct-count": 49, + "nil%": 0 + }, + "type": { + "type/Text": { + "percent-json": 0, + "percent-url": 0, + "percent-email": 0, + "percent-state": 1, + "average-length": 2 + } + } + }, + "base_type": "type/Text" + }, + { + "description": "The date and time an order was submitted.", + "semantic_type": "type/CreationTimestamp", + "coercion_strategy": null, + "unit": "month", + "name": "CREATED_AT", + "settings": null, + "fk_target_field_id": null, + "field_ref": [ + "field", + 41, + { + "base-type": "type/DateTime", + "temporal-unit": "month" + } + ], + "effective_type": "type/DateTime", + "id": 41, + "visibility_type": "normal", + "display_name": "Created At", + "fingerprint": { + "global": { + "distinct-count": 10001, + "nil%": 0 + }, + "type": { + "type/DateTime": { + "earliest": "2022-04-30T18:56:13.352Z", + "latest": "2026-04-19T14:07:15.657Z" + } + } + }, + "base_type": "type/DateTime" + }, + { + "display_name": "Count", + "semantic_type": "type/Quantity", + "field_ref": ["aggregation", 0], + "base_type": "type/BigInteger", + "effective_type": "type/BigInteger", + "name": "count", + "fingerprint": { + "global": { + "distinct-count": 12, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 1, + "q1": 1.8849307066960952, + "q3": 5.5, + "max": 12, + "sd": 2.737211604119948, + "avg": 4.086956521739131 + } + } + } + } + ], + "creator": { + "email": "anton@metabase.test", + "first_name": "Anton", + "last_login": "2024-09-24T15:34:26.000532+01:00", + "is_qbnewb": false, + "is_superuser": true, + "id": 1, + "last_name": "Kulyk", + "date_joined": "2024-08-19T15:09:37.030585+01:00", + "common_name": "Anton Kulyk" + }, + "initially_published_at": null, + "can_write": true, + "database_id": 1, + "enable_embedding": false, + "collection_id": null, + "query_type": "query", + "name": "Bar chart with \"Other\"", + "last_query_start": "2024-10-04T14:53:58.731799+01:00", + "dashboard_count": 1, + "last_used_at": "2024-10-04T14:53:58.783195+01:00", + "type": "question", + "average_query_time": 71.96116504854369, + "creator_id": 1, + "can_restore": false, + "moderation_reviews": [], + "updated_at": "2024-10-04T15:00:55.349248+01:00", + "made_public_by_id": null, + "embedding_params": null, + "cache_ttl": null, + "dataset_query": { + "database": 1, + "type": "query", + "query": { + "source-table": 5, + "aggregation": [["count"]], + "breakout": [ + [ + "field", + 48, + { + "base-type": "type/Text", + "source-field": 43 + } + ], + [ + "field", + 41, + { + "base-type": "type/DateTime", + "temporal-unit": "month" + } + ] + ], + "filter": [ + "and", + [ + "between", + [ + "field", + 41, + { + "base-type": "type/DateTime", + "temporal-unit": "month" + } + ], + "2022-09-01T00:00Z", + "2023-02-01T00:00Z" + ], + [ + "=", + [ + "field", + 48, + { + "base-type": "type/Text", + "source-field": 43 + } + ], + "AK", + "AL", + "AR", + "AZ", + "CA", + "CO", + "CT", + "DE", + "FL", + "GA", + "IA", + "ID", + "IL", + "KY" + ] + ] + } + }, + "id": 47, + "parameter_mappings": [], + "display": "bar", + "archived_directly": false, + "entity_id": "ez2Yb1JhGrltIJMNwZfwU", + "collection_preview": true, + "last-edit-info": { + "timestamp": "2024-10-04T14:00:55.394Z", + "id": 1, + "first_name": "Anton", + "last_name": "Kulyk", + "email": "anton@metabase.test" + }, + "visualization_settings": { + "graph.max_categories_enabled": true, + "graph.max_categories": 10, + "graph.dimensions": ["CREATED_AT", "STATE"], + "graph.series_order": [ + { + "key": "AK", + "color": "#509EE3", + "enabled": true, + "name": "AK" + }, + { + "key": "AL", + "color": "#227FD2", + "enabled": true, + "name": "AL" + }, + { + "key": "AR", + "color": "#88BF4D", + "enabled": true, + "name": "AR" + }, + { + "key": "AZ", + "color": "#689636", + "enabled": true, + "name": "AZ" + }, + { + "key": "CA", + "color": "#A989C5", + "enabled": true, + "name": "CA" + }, + { + "key": "CO", + "color": "#8A5EB0", + "enabled": true, + "name": "CO" + }, + { + "key": "CT", + "color": "#EF8C8C", + "enabled": true, + "name": "CT" + }, + { + "key": "DE", + "color": "#E75454", + "enabled": true, + "name": "DE" + }, + { + "key": "GA", + "color": "#F9D45C", + "enabled": true, + "name": "GA" + }, + { + "key": "IA", + "color": "#F7C41F", + "enabled": true, + "name": "IA" + }, + { + "key": "ID", + "color": "#F2A86F", + "enabled": true, + "name": "ID" + }, + { + "key": "KY", + "color": "#ED8535", + "enabled": true, + "name": "KY" + }, + { + "key": "LA", + "color": "#98D9D9", + "enabled": true, + "name": "LA" + } + ], + "graph.series_order_dimension": "STATE", + "stackable.stack_type": "normalized", + "pie.dimension": ["STATE"], + "graph.metrics": ["count"] + }, + "collection": null, + "metabase_version": "v0.1.37-SNAPSHOT (5b4a5d6)", + "parameters": [], + "created_at": "2024-10-01T13:37:28.812936+01:00", + "parameter_usage_count": 0, + "public_uuid": null, + "can_delete": false + }, + "data": { + "rows": [ + ["AK", "2022-09-01T00:00:00+01:00", 2], + ["AK", "2022-10-01T00:00:00+01:00", 3], + ["AK", "2022-11-01T00:00:00Z", 1], + ["AK", "2022-12-01T00:00:00Z", 3], + ["AK", "2023-01-01T00:00:00Z", 9], + ["AK", "2023-02-01T00:00:00Z", 4], + ["AL", "2022-09-01T00:00:00+01:00", 1], + ["AL", "2022-10-01T00:00:00+01:00", 3], + ["AL", "2022-11-01T00:00:00Z", 2], + ["AL", "2022-12-01T00:00:00Z", 6], + ["AL", "2023-01-01T00:00:00Z", 6], + ["AL", "2023-02-01T00:00:00Z", 6], + ["AR", "2022-10-01T00:00:00+01:00", 2], + ["AR", "2022-11-01T00:00:00Z", 4], + ["AR", "2022-12-01T00:00:00Z", 3], + ["AR", "2023-01-01T00:00:00Z", 4], + ["AR", "2023-02-01T00:00:00Z", 1], + ["AZ", "2023-01-01T00:00:00Z", 1], + ["AZ", "2023-02-01T00:00:00Z", 1], + ["CA", "2022-09-01T00:00:00+01:00", 5], + ["CA", "2022-10-01T00:00:00+01:00", 5], + ["CA", "2022-11-01T00:00:00Z", 4], + ["CA", "2022-12-01T00:00:00Z", 6], + ["CA", "2023-01-01T00:00:00Z", 11], + ["CA", "2023-02-01T00:00:00Z", 11], + ["CO", "2022-09-01T00:00:00+01:00", 4], + ["CO", "2022-10-01T00:00:00+01:00", 6], + ["CO", "2022-11-01T00:00:00Z", 12], + ["CO", "2022-12-01T00:00:00Z", 8], + ["CO", "2023-01-01T00:00:00Z", 7], + ["CO", "2023-02-01T00:00:00Z", 9], + ["CT", "2022-10-01T00:00:00+01:00", 1], + ["DE", "2022-10-01T00:00:00+01:00", 1], + ["DE", "2022-12-01T00:00:00Z", 1], + ["FL", "2022-09-01T00:00:00+01:00", 1], + ["FL", "2022-10-01T00:00:00+01:00", 2], + ["FL", "2022-11-01T00:00:00Z", 4], + ["FL", "2023-01-01T00:00:00Z", 3], + ["FL", "2023-02-01T00:00:00Z", 4], + ["GA", "2022-09-01T00:00:00+01:00", 1], + ["GA", "2022-10-01T00:00:00+01:00", 7], + ["GA", "2022-11-01T00:00:00Z", 3], + ["GA", "2022-12-01T00:00:00Z", 1], + ["GA", "2023-01-01T00:00:00Z", 8], + ["GA", "2023-02-01T00:00:00Z", 3], + ["IA", "2022-09-01T00:00:00+01:00", 3], + ["IA", "2022-10-01T00:00:00+01:00", 4], + ["IA", "2022-11-01T00:00:00Z", 5], + ["IA", "2022-12-01T00:00:00Z", 5], + ["IA", "2023-01-01T00:00:00Z", 10], + ["IA", "2023-02-01T00:00:00Z", 7], + ["ID", "2022-09-01T00:00:00+01:00", 1], + ["ID", "2022-10-01T00:00:00+01:00", 1], + ["ID", "2022-11-01T00:00:00Z", 1], + ["ID", "2022-12-01T00:00:00Z", 2], + ["ID", "2023-01-01T00:00:00Z", 3], + ["ID", "2023-02-01T00:00:00Z", 4], + ["IL", "2022-09-01T00:00:00+01:00", 1], + ["IL", "2022-10-01T00:00:00+01:00", 3], + ["IL", "2022-11-01T00:00:00Z", 3], + ["IL", "2022-12-01T00:00:00Z", 6], + ["IL", "2023-01-01T00:00:00Z", 5], + ["IL", "2023-02-01T00:00:00Z", 5], + ["KY", "2022-09-01T00:00:00+01:00", 2], + ["KY", "2022-10-01T00:00:00+01:00", 5], + ["KY", "2022-11-01T00:00:00Z", 5], + ["KY", "2022-12-01T00:00:00Z", 4], + ["KY", "2023-01-01T00:00:00Z", 4], + ["KY", "2023-02-01T00:00:00Z", 3] + ], + "cols": [ + { + "description": "The state or province of the account’s billing address", + "database_type": "CHARACTER", + "semantic_type": "type/State", + "table_id": 3, + "coercion_strategy": null, + "name": "STATE", + "settings": null, + "source": "breakout", + "fk_target_field_id": null, + "fk_field_id": 43, + "field_ref": [ + "field", + 48, + { + "base-type": "type/Text", + "source-field": 43 + } + ], + "effective_type": "type/Text", + "nfc_path": null, + "parent_id": null, + "id": 48, + "position": 7, + "visibility_type": "normal", + "display_name": "User → State", + "fingerprint": { + "global": { + "distinct-count": 49, + "nil%": 0.0 + }, + "type": { + "type/Text": { + "percent-json": 0.0, + "percent-url": 0.0, + "percent-email": 0.0, + "percent-state": 1.0, + "average-length": 2.0 + } + } + }, + "base_type": "type/Text", + "source_alias": "PEOPLE__via__USER_ID" + }, + { + "description": "The date and time an order was submitted.", + "database_type": "TIMESTAMP", + "semantic_type": "type/CreationTimestamp", + "table_id": 5, + "coercion_strategy": null, + "unit": "month", + "name": "CREATED_AT", + "settings": null, + "source": "breakout", + "fk_target_field_id": null, + "field_ref": [ + "field", + 41, + { + "base-type": "type/DateTime", + "temporal-unit": "month" + } + ], + "effective_type": "type/DateTime", + "nfc_path": null, + "parent_id": null, + "id": 41, + "position": 7, + "visibility_type": "normal", + "display_name": "Created At", + "fingerprint": { + "global": { + "distinct-count": 10001, + "nil%": 0.0 + }, + "type": { + "type/DateTime": { + "earliest": "2022-04-30T18:56:13.352Z", + "latest": "2026-04-19T14:07:15.657Z" + } + } + }, + "base_type": "type/DateTime" + }, + { + "database_type": "BIGINT", + "semantic_type": "type/Quantity", + "name": "count", + "source": "aggregation", + "field_ref": ["aggregation", 0], + "effective_type": "type/BigInteger", + "aggregation_index": 0, + "display_name": "Count", + "base_type": "type/BigInteger" + } + ], + "native_form": { + "query": "SELECT \"PEOPLE__via__USER_ID\".\"STATE\" AS \"PEOPLE__via__USER_ID__STATE\", DATE_TRUNC('month', \"PUBLIC\".\"ORDERS\".\"CREATED_AT\") AS \"CREATED_AT\", COUNT(*) AS \"count\" FROM \"PUBLIC\".\"ORDERS\" LEFT JOIN \"PUBLIC\".\"PEOPLE\" AS \"PEOPLE__via__USER_ID\" ON \"PUBLIC\".\"ORDERS\".\"USER_ID\" = \"PEOPLE__via__USER_ID\".\"ID\" WHERE (\"PUBLIC\".\"ORDERS\".\"CREATED_AT\" >= timestamp '2022-09-01 00:00:00.000') AND (\"PUBLIC\".\"ORDERS\".\"CREATED_AT\" < timestamp '2023-03-01 00:00:00.000') AND ((\"PEOPLE__via__USER_ID\".\"STATE\" = 'AK') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'AL') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'AR') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'AZ') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'CA') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'CO') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'CT') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'DE') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'FL') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'GA') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'IA') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'ID') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'IL') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'KY')) GROUP BY \"PEOPLE__via__USER_ID\".\"STATE\", DATE_TRUNC('month', \"PUBLIC\".\"ORDERS\".\"CREATED_AT\") ORDER BY \"PEOPLE__via__USER_ID\".\"STATE\" ASC, DATE_TRUNC('month', \"PUBLIC\".\"ORDERS\".\"CREATED_AT\") ASC", + "params": null + }, + "format-rows?": true, + "results_timezone": "Europe/Lisbon", + "results_metadata": { + "columns": [ + { + "description": "The state or province of the account’s billing address", + "semantic_type": "type/State", + "coercion_strategy": null, + "name": "STATE", + "settings": null, + "fk_target_field_id": null, + "field_ref": [ + "field", + 48, + { + "base-type": "type/Text", + "source-field": 43 + } + ], + "effective_type": "type/Text", + "id": 48, + "visibility_type": "normal", + "display_name": "User → State", + "fingerprint": { + "global": { + "distinct-count": 49, + "nil%": 0.0 + }, + "type": { + "type/Text": { + "percent-json": 0.0, + "percent-url": 0.0, + "percent-email": 0.0, + "percent-state": 1.0, + "average-length": 2.0 + } + } + }, + "base_type": "type/Text" + }, + { + "description": "The date and time an order was submitted.", + "semantic_type": "type/CreationTimestamp", + "coercion_strategy": null, + "unit": "month", + "name": "CREATED_AT", + "settings": null, + "fk_target_field_id": null, + "field_ref": [ + "field", + 41, + { + "base-type": "type/DateTime", + "temporal-unit": "month" + } + ], + "effective_type": "type/DateTime", + "id": 41, + "visibility_type": "normal", + "display_name": "Created At", + "fingerprint": { + "global": { + "distinct-count": 10001, + "nil%": 0.0 + }, + "type": { + "type/DateTime": { + "earliest": "2022-04-30T18:56:13.352Z", + "latest": "2026-04-19T14:07:15.657Z" + } + } + }, + "base_type": "type/DateTime" + }, + { + "display_name": "Count", + "semantic_type": "type/Quantity", + "field_ref": ["aggregation", 0], + "base_type": "type/BigInteger", + "effective_type": "type/BigInteger", + "name": "count", + "fingerprint": { + "global": { + "distinct-count": 12, + "nil%": 0.0 + }, + "type": { + "type/Number": { + "min": 1.0, + "q1": 1.8849307066960952, + "q3": 5.5, + "max": 12.0, + "sd": 2.737211604119948, + "avg": 4.086956521739131 + } + } + } + } + ] + }, + "insights": [ + { + "previous-value": 4, + "unit": "month", + "offset": -377.8969468095498, + "last-change": -0.25, + "col": "count", + "slope": 0.019777876708186145, + "last-value": 3, + "best-fit": [ + "*", + 4.201731368215701e-45, + ["exp", ["*", 0.005350764152580669, "x"]] + ] + } + ] + } + } +] diff --git a/frontend/src/metabase/static-viz/components/ComboChart/stories-data/bar-max-categories-stacked.json b/frontend/src/metabase/static-viz/components/ComboChart/stories-data/bar-max-categories-stacked.json new file mode 100644 index 00000000000..1dd35791d49 --- /dev/null +++ b/frontend/src/metabase/static-viz/components/ComboChart/stories-data/bar-max-categories-stacked.json @@ -0,0 +1,606 @@ +[ + { + "card": { + "cache_invalidated_at": null, + "description": null, + "archived": false, + "view_count": 151, + "collection_position": null, + "source_card_id": null, + "table_id": 5, + "can_run_adhoc_query": true, + "result_metadata": [ + { + "description": "The state or province of the account’s billing address", + "semantic_type": "type/State", + "coercion_strategy": null, + "name": "STATE", + "settings": null, + "fk_target_field_id": null, + "field_ref": [ + "field", + 48, + { + "base-type": "type/Text", + "source-field": 43 + } + ], + "effective_type": "type/Text", + "id": 48, + "visibility_type": "normal", + "display_name": "User → State", + "fingerprint": { + "global": { + "distinct-count": 49, + "nil%": 0 + }, + "type": { + "type/Text": { + "percent-json": 0, + "percent-url": 0, + "percent-email": 0, + "percent-state": 1, + "average-length": 2 + } + } + }, + "base_type": "type/Text" + }, + { + "description": "The date and time an order was submitted.", + "semantic_type": "type/CreationTimestamp", + "coercion_strategy": null, + "unit": "month", + "name": "CREATED_AT", + "settings": null, + "fk_target_field_id": null, + "field_ref": [ + "field", + 41, + { + "base-type": "type/DateTime", + "temporal-unit": "month" + } + ], + "effective_type": "type/DateTime", + "id": 41, + "visibility_type": "normal", + "display_name": "Created At", + "fingerprint": { + "global": { + "distinct-count": 10001, + "nil%": 0 + }, + "type": { + "type/DateTime": { + "earliest": "2022-04-30T18:56:13.352Z", + "latest": "2026-04-19T14:07:15.657Z" + } + } + }, + "base_type": "type/DateTime" + }, + { + "display_name": "Count", + "semantic_type": "type/Quantity", + "field_ref": ["aggregation", 0], + "base_type": "type/BigInteger", + "effective_type": "type/BigInteger", + "name": "count", + "fingerprint": { + "global": { + "distinct-count": 12, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 1, + "q1": 1.8849307066960952, + "q3": 5.5, + "max": 12, + "sd": 2.737211604119948, + "avg": 4.086956521739131 + } + } + } + } + ], + "creator": { + "email": "anton@metabase.test", + "first_name": "Anton", + "last_login": "2024-09-24T15:34:26.000532+01:00", + "is_qbnewb": false, + "is_superuser": true, + "id": 1, + "last_name": "Kulyk", + "date_joined": "2024-08-19T15:09:37.030585+01:00", + "common_name": "Anton Kulyk" + }, + "initially_published_at": null, + "can_write": true, + "database_id": 1, + "enable_embedding": false, + "collection_id": null, + "query_type": "query", + "name": "Bar chart with \"Other\"", + "last_query_start": "2024-10-04T14:53:58.731799+01:00", + "dashboard_count": 1, + "last_used_at": "2024-10-04T14:53:58.783195+01:00", + "type": "question", + "average_query_time": 71.96116504854369, + "creator_id": 1, + "can_restore": false, + "moderation_reviews": [], + "updated_at": "2024-10-04T14:58:53.488082+01:00", + "made_public_by_id": null, + "embedding_params": null, + "cache_ttl": null, + "dataset_query": { + "database": 1, + "type": "query", + "query": { + "source-table": 5, + "aggregation": [["count"]], + "breakout": [ + [ + "field", + 48, + { + "base-type": "type/Text", + "source-field": 43 + } + ], + [ + "field", + 41, + { + "base-type": "type/DateTime", + "temporal-unit": "month" + } + ] + ], + "filter": [ + "and", + [ + "between", + [ + "field", + 41, + { + "base-type": "type/DateTime", + "temporal-unit": "month" + } + ], + "2022-09-01T00:00Z", + "2023-02-01T00:00Z" + ], + [ + "=", + [ + "field", + 48, + { + "base-type": "type/Text", + "source-field": 43 + } + ], + "AK", + "AL", + "AR", + "AZ", + "CA", + "CO", + "CT", + "DE", + "FL", + "GA", + "IA", + "ID", + "IL", + "KY" + ] + ] + } + }, + "id": 47, + "parameter_mappings": [], + "display": "bar", + "archived_directly": false, + "entity_id": "ez2Yb1JhGrltIJMNwZfwU", + "collection_preview": true, + "last-edit-info": { + "timestamp": "2024-10-04T13:58:53.546Z", + "id": 1, + "first_name": "Anton", + "last_name": "Kulyk", + "email": "anton@metabase.test" + }, + "visualization_settings": { + "graph.max_categories_enabled": true, + "graph.max_categories": 4, + "graph.dimensions": ["CREATED_AT", "STATE"], + "graph.series_order": [ + { + "key": "AK", + "color": "#509EE3", + "enabled": true, + "name": "AK" + }, + { + "key": "AL", + "color": "#227FD2", + "enabled": true, + "name": "AL" + }, + { + "key": "AR", + "color": "#88BF4D", + "enabled": true, + "name": "AR" + }, + { + "key": "AZ", + "color": "#689636", + "enabled": true, + "name": "AZ" + }, + { + "key": "CA", + "color": "#A989C5", + "enabled": true, + "name": "CA" + }, + { + "key": "CO", + "color": "#8A5EB0", + "enabled": true, + "name": "CO" + }, + { + "key": "CT", + "color": "#EF8C8C", + "enabled": true, + "name": "CT" + }, + { + "key": "DE", + "color": "#E75454", + "enabled": true, + "name": "DE" + }, + { + "key": "GA", + "color": "#F9D45C", + "enabled": true, + "name": "GA" + }, + { + "key": "IA", + "color": "#F7C41F", + "enabled": true, + "name": "IA" + }, + { + "key": "ID", + "color": "#F2A86F", + "enabled": true, + "name": "ID" + }, + { + "key": "KY", + "color": "#ED8535", + "enabled": true, + "name": "KY" + }, + { + "key": "LA", + "color": "#98D9D9", + "enabled": true, + "name": "LA" + } + ], + "graph.series_order_dimension": "STATE", + "stackable.stack_type": "stacked", + "pie.dimension": ["STATE"], + "graph.metrics": ["count"] + }, + "collection": null, + "metabase_version": "v0.1.37-SNAPSHOT (5b4a5d6)", + "parameters": [], + "created_at": "2024-10-01T13:37:28.812936+01:00", + "parameter_usage_count": 0, + "public_uuid": null, + "can_delete": false + }, + "data": { + "rows": [ + ["AK", "2022-09-01T00:00:00+01:00", 2], + ["AK", "2022-10-01T00:00:00+01:00", 3], + ["AK", "2022-11-01T00:00:00Z", 1], + ["AK", "2022-12-01T00:00:00Z", 3], + ["AK", "2023-01-01T00:00:00Z", 9], + ["AK", "2023-02-01T00:00:00Z", 4], + ["AL", "2022-09-01T00:00:00+01:00", 1], + ["AL", "2022-10-01T00:00:00+01:00", 3], + ["AL", "2022-11-01T00:00:00Z", 2], + ["AL", "2022-12-01T00:00:00Z", 6], + ["AL", "2023-01-01T00:00:00Z", 6], + ["AL", "2023-02-01T00:00:00Z", 6], + ["AR", "2022-10-01T00:00:00+01:00", 2], + ["AR", "2022-11-01T00:00:00Z", 4], + ["AR", "2022-12-01T00:00:00Z", 3], + ["AR", "2023-01-01T00:00:00Z", 4], + ["AR", "2023-02-01T00:00:00Z", 1], + ["AZ", "2023-01-01T00:00:00Z", 1], + ["AZ", "2023-02-01T00:00:00Z", 1], + ["CA", "2022-09-01T00:00:00+01:00", 5], + ["CA", "2022-10-01T00:00:00+01:00", 5], + ["CA", "2022-11-01T00:00:00Z", 4], + ["CA", "2022-12-01T00:00:00Z", 6], + ["CA", "2023-01-01T00:00:00Z", 11], + ["CA", "2023-02-01T00:00:00Z", 11], + ["CO", "2022-09-01T00:00:00+01:00", 4], + ["CO", "2022-10-01T00:00:00+01:00", 6], + ["CO", "2022-11-01T00:00:00Z", 12], + ["CO", "2022-12-01T00:00:00Z", 8], + ["CO", "2023-01-01T00:00:00Z", 7], + ["CO", "2023-02-01T00:00:00Z", 9], + ["CT", "2022-10-01T00:00:00+01:00", 1], + ["DE", "2022-10-01T00:00:00+01:00", 1], + ["DE", "2022-12-01T00:00:00Z", 1], + ["FL", "2022-09-01T00:00:00+01:00", 1], + ["FL", "2022-10-01T00:00:00+01:00", 2], + ["FL", "2022-11-01T00:00:00Z", 4], + ["FL", "2023-01-01T00:00:00Z", 3], + ["FL", "2023-02-01T00:00:00Z", 4], + ["GA", "2022-09-01T00:00:00+01:00", 1], + ["GA", "2022-10-01T00:00:00+01:00", 7], + ["GA", "2022-11-01T00:00:00Z", 3], + ["GA", "2022-12-01T00:00:00Z", 1], + ["GA", "2023-01-01T00:00:00Z", 8], + ["GA", "2023-02-01T00:00:00Z", 3], + ["IA", "2022-09-01T00:00:00+01:00", 3], + ["IA", "2022-10-01T00:00:00+01:00", 4], + ["IA", "2022-11-01T00:00:00Z", 5], + ["IA", "2022-12-01T00:00:00Z", 5], + ["IA", "2023-01-01T00:00:00Z", 10], + ["IA", "2023-02-01T00:00:00Z", 7], + ["ID", "2022-09-01T00:00:00+01:00", 1], + ["ID", "2022-10-01T00:00:00+01:00", 1], + ["ID", "2022-11-01T00:00:00Z", 1], + ["ID", "2022-12-01T00:00:00Z", 2], + ["ID", "2023-01-01T00:00:00Z", 3], + ["ID", "2023-02-01T00:00:00Z", 4], + ["IL", "2022-09-01T00:00:00+01:00", 1], + ["IL", "2022-10-01T00:00:00+01:00", 3], + ["IL", "2022-11-01T00:00:00Z", 3], + ["IL", "2022-12-01T00:00:00Z", 6], + ["IL", "2023-01-01T00:00:00Z", 5], + ["IL", "2023-02-01T00:00:00Z", 5], + ["KY", "2022-09-01T00:00:00+01:00", 2], + ["KY", "2022-10-01T00:00:00+01:00", 5], + ["KY", "2022-11-01T00:00:00Z", 5], + ["KY", "2022-12-01T00:00:00Z", 4], + ["KY", "2023-01-01T00:00:00Z", 4], + ["KY", "2023-02-01T00:00:00Z", 3] + ], + "cols": [ + { + "description": "The state or province of the account’s billing address", + "database_type": "CHARACTER", + "semantic_type": "type/State", + "table_id": 3, + "coercion_strategy": null, + "name": "STATE", + "settings": null, + "source": "breakout", + "fk_target_field_id": null, + "fk_field_id": 43, + "field_ref": [ + "field", + 48, + { + "base-type": "type/Text", + "source-field": 43 + } + ], + "effective_type": "type/Text", + "nfc_path": null, + "parent_id": null, + "id": 48, + "position": 7, + "visibility_type": "normal", + "display_name": "User → State", + "fingerprint": { + "global": { + "distinct-count": 49, + "nil%": 0.0 + }, + "type": { + "type/Text": { + "percent-json": 0.0, + "percent-url": 0.0, + "percent-email": 0.0, + "percent-state": 1.0, + "average-length": 2.0 + } + } + }, + "base_type": "type/Text", + "source_alias": "PEOPLE__via__USER_ID" + }, + { + "description": "The date and time an order was submitted.", + "database_type": "TIMESTAMP", + "semantic_type": "type/CreationTimestamp", + "table_id": 5, + "coercion_strategy": null, + "unit": "month", + "name": "CREATED_AT", + "settings": null, + "source": "breakout", + "fk_target_field_id": null, + "field_ref": [ + "field", + 41, + { + "base-type": "type/DateTime", + "temporal-unit": "month" + } + ], + "effective_type": "type/DateTime", + "nfc_path": null, + "parent_id": null, + "id": 41, + "position": 7, + "visibility_type": "normal", + "display_name": "Created At", + "fingerprint": { + "global": { + "distinct-count": 10001, + "nil%": 0.0 + }, + "type": { + "type/DateTime": { + "earliest": "2022-04-30T18:56:13.352Z", + "latest": "2026-04-19T14:07:15.657Z" + } + } + }, + "base_type": "type/DateTime" + }, + { + "database_type": "BIGINT", + "semantic_type": "type/Quantity", + "name": "count", + "source": "aggregation", + "field_ref": ["aggregation", 0], + "effective_type": "type/BigInteger", + "aggregation_index": 0, + "display_name": "Count", + "base_type": "type/BigInteger" + } + ], + "native_form": { + "query": "SELECT \"PEOPLE__via__USER_ID\".\"STATE\" AS \"PEOPLE__via__USER_ID__STATE\", DATE_TRUNC('month', \"PUBLIC\".\"ORDERS\".\"CREATED_AT\") AS \"CREATED_AT\", COUNT(*) AS \"count\" FROM \"PUBLIC\".\"ORDERS\" LEFT JOIN \"PUBLIC\".\"PEOPLE\" AS \"PEOPLE__via__USER_ID\" ON \"PUBLIC\".\"ORDERS\".\"USER_ID\" = \"PEOPLE__via__USER_ID\".\"ID\" WHERE (\"PUBLIC\".\"ORDERS\".\"CREATED_AT\" >= timestamp '2022-09-01 00:00:00.000') AND (\"PUBLIC\".\"ORDERS\".\"CREATED_AT\" < timestamp '2023-03-01 00:00:00.000') AND ((\"PEOPLE__via__USER_ID\".\"STATE\" = 'AK') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'AL') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'AR') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'AZ') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'CA') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'CO') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'CT') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'DE') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'FL') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'GA') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'IA') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'ID') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'IL') OR (\"PEOPLE__via__USER_ID\".\"STATE\" = 'KY')) GROUP BY \"PEOPLE__via__USER_ID\".\"STATE\", DATE_TRUNC('month', \"PUBLIC\".\"ORDERS\".\"CREATED_AT\") ORDER BY \"PEOPLE__via__USER_ID\".\"STATE\" ASC, DATE_TRUNC('month', \"PUBLIC\".\"ORDERS\".\"CREATED_AT\") ASC", + "params": null + }, + "format-rows?": true, + "results_timezone": "Europe/Lisbon", + "results_metadata": { + "columns": [ + { + "description": "The state or province of the account’s billing address", + "semantic_type": "type/State", + "coercion_strategy": null, + "name": "STATE", + "settings": null, + "fk_target_field_id": null, + "field_ref": [ + "field", + 48, + { + "base-type": "type/Text", + "source-field": 43 + } + ], + "effective_type": "type/Text", + "id": 48, + "visibility_type": "normal", + "display_name": "User → State", + "fingerprint": { + "global": { + "distinct-count": 49, + "nil%": 0.0 + }, + "type": { + "type/Text": { + "percent-json": 0.0, + "percent-url": 0.0, + "percent-email": 0.0, + "percent-state": 1.0, + "average-length": 2.0 + } + } + }, + "base_type": "type/Text" + }, + { + "description": "The date and time an order was submitted.", + "semantic_type": "type/CreationTimestamp", + "coercion_strategy": null, + "unit": "month", + "name": "CREATED_AT", + "settings": null, + "fk_target_field_id": null, + "field_ref": [ + "field", + 41, + { + "base-type": "type/DateTime", + "temporal-unit": "month" + } + ], + "effective_type": "type/DateTime", + "id": 41, + "visibility_type": "normal", + "display_name": "Created At", + "fingerprint": { + "global": { + "distinct-count": 10001, + "nil%": 0.0 + }, + "type": { + "type/DateTime": { + "earliest": "2022-04-30T18:56:13.352Z", + "latest": "2026-04-19T14:07:15.657Z" + } + } + }, + "base_type": "type/DateTime" + }, + { + "display_name": "Count", + "semantic_type": "type/Quantity", + "field_ref": ["aggregation", 0], + "base_type": "type/BigInteger", + "effective_type": "type/BigInteger", + "name": "count", + "fingerprint": { + "global": { + "distinct-count": 12, + "nil%": 0.0 + }, + "type": { + "type/Number": { + "min": 1.0, + "q1": 1.8849307066960952, + "q3": 5.5, + "max": 12.0, + "sd": 2.737211604119948, + "avg": 4.086956521739131 + } + } + } + } + ] + }, + "insights": [ + { + "previous-value": 4, + "unit": "month", + "offset": -377.8969468095498, + "last-change": -0.25, + "col": "count", + "slope": 0.019777876708186145, + "last-value": 3, + "best-fit": [ + "*", + 4.201731368215701e-45, + ["exp", ["*", 0.005350764152580669, "x"]] + ] + } + ] + } + } +] 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 d6a3293f551..89c4f17c37d 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 @@ -27,6 +27,9 @@ import barHistogramXScale from "./bar-histogram-x-scale.json"; import barLinearXScale from "./bar-linear-x-scale.json"; import barLogYScaleStackedNegative from "./bar-log-y-scale-stacked-negative.json"; import barLogYScaleStacked from "./bar-log-y-scale-stacked.json"; +import barMaxCategoriesDefault from "./bar-max-categories-default.json"; +import barMaxCategoriesStackedNormalized from "./bar-max-categories-stacked-normalized.json"; +import barMaxCategoriesStacked from "./bar-max-categories-stacked.json"; import barMinHeightLimit from "./bar-min-height-limit.json"; import barOrdinalXScaleAutoRotatedLabels from "./bar-ordinal-x-scale-auto-rotated-labels.json"; import barOrdinalXScale from "./bar-ordinal-x-scale.json"; @@ -226,6 +229,9 @@ export const data = { barStackedSeriesLabelsAndTotalsOrdinal, barStackedSeriesLabelsNormalizedAutoCompactness, barStackedLabelsNullVsZero, + barMaxCategoriesDefault, + barMaxCategoriesStacked, + barMaxCategoriesStackedNormalized, barMinHeightLimit, comboDataLabelsAutoCompactnessPropagatesFromLine, comboDataLabelsAutoCompactnessPropagatesFromTotals, diff --git a/frontend/src/metabase/visualizations/components/ChartTooltip/EChartsTooltip/EChartsTooltip.tsx b/frontend/src/metabase/visualizations/components/ChartTooltip/EChartsTooltip/EChartsTooltip.tsx index a6ba65c316c..3e4f33e2c47 100644 --- a/frontend/src/metabase/visualizations/components/ChartTooltip/EChartsTooltip/EChartsTooltip.tsx +++ b/frontend/src/metabase/visualizations/components/ChartTooltip/EChartsTooltip/EChartsTooltip.tsx @@ -6,6 +6,10 @@ import { isNotNull } from "metabase/lib/types"; import TooltipStyles from "./EChartsTooltip.module.css"; +const getPaddedValuesArray = (values: React.ReactNode[], maxValues: number) => { + return Object.assign(Array(maxValues).fill(null), values.slice(0, maxValues)); +}; + export interface EChartsTooltipRow { /* We pass CSS class with marker colors because setting styles in tooltip rendered by ECharts violates CSP */ markerColorClass?: string; @@ -40,14 +44,9 @@ export const EChartsTooltip = ({ }, 0); const paddedRows = rows.map(row => { - const paddedValues = Object.assign( - Array(maxValuesColumns).fill(null), - row.values.slice(0, maxValuesColumns), - ); - return { ...row, - values: paddedValues, + values: getPaddedValuesArray(row.values, maxValuesColumns), }; }); @@ -79,6 +78,7 @@ export const EChartsTooltip = ({ <tfoot data-testid="echarts-tooltip-footer"> <FooterRow {...footer} + values={getPaddedValuesArray(footer.values, maxValuesColumns)} markerContent={hasMarkers ? <span /> : null} /> </tfoot> diff --git a/frontend/src/metabase/visualizations/components/ClickActions/ClickActionsView.tsx b/frontend/src/metabase/visualizations/components/ClickActions/ClickActionsView.tsx index 7d874c2e1fb..8e5306cc365 100644 --- a/frontend/src/metabase/visualizations/components/ClickActions/ClickActionsView.tsx +++ b/frontend/src/metabase/visualizations/components/ClickActions/ClickActionsView.tsx @@ -25,7 +25,7 @@ export const ClickActionsView = ({ const hasOnlyOneSection = sections.length === 1; return ( - <Container> + <Container data-testid="click-actions-view"> {sections.map(([sectionKey, actions]) => { const sectionTitle = getSectionTitle(sectionKey, actions); const contentDirection = getSectionContentDirection( diff --git a/frontend/src/metabase/visualizations/components/settings/ChartNestedSettingsSeriesMultiple.unit.spec.js b/frontend/src/metabase/visualizations/components/settings/ChartNestedSettingsSeriesMultiple.unit.spec.js index b45bbc68640..dd85bd52d42 100644 --- a/frontend/src/metabase/visualizations/components/settings/ChartNestedSettingsSeriesMultiple.unit.spec.js +++ b/frontend/src/metabase/visualizations/components/settings/ChartNestedSettingsSeriesMultiple.unit.spec.js @@ -4,12 +4,13 @@ import userEvent from "@testing-library/user-event"; import { renderWithProviders, screen, within } from "__support__/ui"; import { ChartSettings } from "metabase/visualizations/components/ChartSettings"; import registerVisualizations from "metabase/visualizations/register"; +import { createMockCard } from "metabase-types/api/mocks"; registerVisualizations(); function getSeries(display, index, changeSeriesName) { return { - card: { + card: createMockCard({ display, visualization_settings: changeSeriesName ? { @@ -19,7 +20,7 @@ function getSeries(display, index, changeSeriesName) { } : {}, name: `Test ${index}`, - }, + }), data: { rows: [ ["a", 1], diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingColorPicker/ChartSettingColorPicker.tsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingColorPicker/ChartSettingColorPicker.tsx index 69811949a1b..ec825a751d1 100644 --- a/frontend/src/metabase/visualizations/components/settings/ChartSettingColorPicker/ChartSettingColorPicker.tsx +++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingColorPicker/ChartSettingColorPicker.tsx @@ -24,7 +24,7 @@ export const ChartSettingColorPicker = ({ accentColorOptions = { main: true, light: true, dark: true, harmony: false }, }: ChartSettingColorPickerProps) => { return ( - <div className={cx(CS.flex, CS.alignCenter, CS.mb1, className)}> + <div className={cx(CS.flex, CS.alignCenter, className)}> <ColorSelector value={value} colors={getAccentColors(accentColorOptions)} diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingColorsPicker.jsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingColorsPicker.jsx index 80eae16ce27..5e382e368d3 100644 --- a/frontend/src/metabase/visualizations/components/settings/ChartSettingColorsPicker.jsx +++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingColorsPicker.jsx @@ -1,6 +1,8 @@ /* eslint-disable react/prop-types */ import { Component } from "react"; +import CS from "metabase/css/core/index.css"; + import { ChartSettingColorPicker } from "./ChartSettingColorPicker"; export default class ChartSettingColorsPicker extends Component { @@ -10,6 +12,7 @@ export default class ChartSettingColorsPicker extends Component { <div> {seriesValues.map((key, index) => ( <ChartSettingColorPicker + className={CS.mb1} key={index} onChange={color => onChange({ diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingFieldPicker.jsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingFieldPicker.jsx index 8caa8715de0..2e32f7fa5f2 100644 --- a/frontend/src/metabase/visualizations/components/settings/ChartSettingFieldPicker.jsx +++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingFieldPicker.jsx @@ -1,4 +1,5 @@ /* eslint-disable react/prop-types */ +import { useMemo } from "react"; import { t } from "ttag"; import _ from "underscore"; @@ -28,6 +29,7 @@ const ChartSettingFieldPicker = ({ colors, series, onChangeSeriesColor, + fieldSettingWidget = null, }) => { let columnKey; if (value && showColumnSetting && columns) { @@ -37,6 +39,25 @@ const ChartSettingFieldPicker = ({ } } + const menuWidgetInfo = useMemo(() => { + if (columnKey && showColumnSetting) { + return { + id: "column_settings", + props: { + initialKey: columnKey, + }, + }; + } + + if (fieldSettingWidget) { + return { + id: fieldSettingWidget, + }; + } + + return null; + }, [columnKey, fieldSettingWidget, showColumnSetting]); + let seriesKey; if (series && columnKey && showColorPicker) { const seriesForColumn = series.find(single => { @@ -73,21 +94,14 @@ const ChartSettingFieldPicker = ({ isInitiallyOpen={value === undefined} hiddenIcons /> - {columnKey && ( + {menuWidgetInfo && ( <SettingsButton onlyIcon icon="ellipsis" onClick={e => { - onShowWidget( - { - id: "column_settings", - props: { - initialKey: columnKey, - }, - }, - e.target, - ); + onShowWidget(menuWidgetInfo, e.target); }} + data-testid={`settings-${value}`} /> )} {onRemove && ( diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingFieldPicker.unit.spec.js b/frontend/src/metabase/visualizations/components/settings/ChartSettingFieldPicker.unit.spec.js index 7616531c2db..ef81f1493ce 100644 --- a/frontend/src/metabase/visualizations/components/settings/ChartSettingFieldPicker.unit.spec.js +++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingFieldPicker.unit.spec.js @@ -4,19 +4,20 @@ import { within } from "@testing-library/react"; import { renderWithProviders, screen } from "__support__/ui"; import { ChartSettings } from "metabase/visualizations/components/ChartSettings"; import registerVisualizations from "metabase/visualizations/register"; +import { createMockCard } from "metabase-types/api/mocks"; registerVisualizations(); function getSeries(metricColumnProps) { return [ { - card: { + card: createMockCard({ display: "line", visualization_settings: { "graph.dimensions": ["FOO"], "graph.metrics": ["BAR"], }, - }, + }), data: { rows: [ ["a", 1], diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingFieldsPicker.jsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingFieldsPicker.jsx index f5ffdaa3c3e..79fc927e22e 100644 --- a/frontend/src/metabase/visualizations/components/settings/ChartSettingFieldsPicker.jsx +++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingFieldsPicker.jsx @@ -17,6 +17,7 @@ const ChartSettingFieldsPicker = ({ addAnother, showColumnSetting, showColumnSettingForIndicies, + fieldSettingWidgets = [], ...props }) => { const handleDragEnd = ({ source, destination }) => { @@ -93,6 +94,7 @@ const ChartSettingFieldsPicker = ({ : null } showDragHandle={fields.length > 1} + fieldSettingWidget={fieldSettingWidgets[fieldIndex]} /> </div> )} diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingInputNumeric.tsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingInputNumeric.tsx index f692dc2bb7e..715af5d8c67 100644 --- a/frontend/src/metabase/visualizations/components/settings/ChartSettingInputNumeric.tsx +++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingInputNumeric.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import _ from "underscore"; import { ChartSettingNumericInput } from "./ChartSettingInputNumeric.styled"; +import type { ChartSettingWidgetProps } from "./types"; const ALLOWED_CHARS = [ "0", @@ -22,10 +23,7 @@ const ALLOWED_CHARS = [ // Note: there are more props than these that are provided by the viz settings // code, we just don't have types for them here. -interface ChartSettingInputProps { - value: number | undefined; - onChange: (value: number | undefined) => void; - onChangeSettings: () => void; +interface ChartSettingInputProps extends ChartSettingWidgetProps<number> { options?: { isInteger?: boolean; isNonNegative?: boolean; diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingMaxCategories.tsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingMaxCategories.tsx new file mode 100644 index 00000000000..9dd8d0fde87 --- /dev/null +++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingMaxCategories.tsx @@ -0,0 +1,85 @@ +import { useCallback } from "react"; +import { t } from "ttag"; + +import { Checkbox, Select, Stack, Text } from "metabase/ui"; +import type { VisualizationSettings } from "metabase-types/api"; + +import { ChartSettingInputNumeric } from "./ChartSettingInputNumeric"; +import type { ChartSettingWidgetProps } from "./types"; + +type AggregationFunction = Exclude< + VisualizationSettings["graph.other_category_aggregation_fn"], + undefined +>; + +export interface ChartSettingMaxCategoriesProps + extends ChartSettingWidgetProps<number> { + isEnabled?: boolean; + aggregationFunction: AggregationFunction; +} + +export const ChartSettingMaxCategories = ({ + isEnabled, + aggregationFunction, + ...props +}: ChartSettingMaxCategoriesProps) => { + const { onChangeSettings } = props; + + const handleToggleMaxNumberOfSeries = useCallback( + (value: boolean) => { + onChangeSettings({ "graph.max_categories_enabled": value }); + }, + [onChangeSettings], + ); + + const handleAggregationFunctionChange = useCallback( + (value: string | null) => { + if (value) { + onChangeSettings({ + "graph.other_category_aggregation_fn": value as AggregationFunction, + }); + } + }, + [onChangeSettings], + ); + + return ( + <Stack spacing="md"> + <Checkbox + checked={isEnabled} + label={t`Enforce maximum number of series`} + onChange={e => handleToggleMaxNumberOfSeries(e.target.checked)} + /> + <ChartSettingInputNumeric + {...props} + data-testid="graph-max-categories-input" + /> + <Text>{t`Series after this number will be grouped into "Other"`}</Text> + <div> + <Text + component="label" + htmlFor="aggregationFunction" + color="var(--mb-color-text-dark)" + fz="sm" + mb="sm" + >{t`Aggregation method for Other group`}</Text> + <Select + name="aggregationFunction" + value={aggregationFunction} + data={AGGREGATION_FN_OPTIONS} + onChange={handleAggregationFunctionChange} + data-testid="graph-other-category-aggregation-fn-picker" + /> + </div> + </Stack> + ); +}; + +const AGGREGATION_FN_OPTIONS = [ + { label: t`Sum`, value: "sum" }, + { label: t`Average`, value: "avg" }, + { label: t`Median`, value: "median" }, + { label: t`Standard deviation`, value: "stddev" }, + { label: t`Min`, value: "min" }, + { label: t`Max`, value: "max" }, +]; diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingOrderedItems/ChartSettingOrderedItems.tsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingOrderedItems/ChartSettingOrderedItems.tsx index 6869dd3b4d0..1e78b76f999 100644 --- a/frontend/src/metabase/visualizations/components/settings/ChartSettingOrderedItems/ChartSettingOrderedItems.tsx +++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingOrderedItems/ChartSettingOrderedItems.tsx @@ -1,7 +1,10 @@ import { PointerSensor, useSensor } from "@dnd-kit/core"; import { useCallback } from "react"; -import type { DragEndEvent } from "metabase/core/components/Sortable"; +import type { + DragEndEvent, + SortableDivider, +} from "metabase/core/components/Sortable"; import { Sortable, SortableList } from "metabase/core/components/Sortable"; import type { AccentColorOptions } from "metabase/lib/colors/types"; import type { IconProps } from "metabase/ui"; @@ -13,6 +16,7 @@ export interface SortableItem { color?: string; icon?: IconProps["name"]; isOther?: boolean; + hideSettings?: boolean; } interface SortableColumnFunctions<T> { @@ -32,6 +36,7 @@ interface ChartSettingOrderedItemsProps<T extends SortableItem> removeIcon?: IconProps["name"]; accentColorOptions?: AccentColorOptions; getItemColor?: (item: SortableItem) => string | undefined; + dividers?: SortableDivider[]; } export function ChartSettingOrderedItems<T extends SortableItem>({ @@ -48,6 +53,7 @@ export function ChartSettingOrderedItems<T extends SortableItem>({ removeIcon, accentColorOptions, getItemColor = item => item.color, + dividers = [], }: ChartSettingOrderedItemsProps<T>) { const isDragDisabled = items.length < 1; const pointerSensor = useSensor(PointerSensor, { @@ -66,7 +72,7 @@ export function ChartSettingOrderedItems<T extends SortableItem>({ <ColumnItem title={getItemName(item)} onEdit={ - onEdit + onEdit && !item.hideSettings ? (targetElement: HTMLElement) => onEdit(item, targetElement) : undefined } @@ -114,6 +120,7 @@ export function ChartSettingOrderedItems<T extends SortableItem>({ items={items} onSortEnd={onSortEnd} sensors={[pointerSensor]} + dividers={dividers} /> ); } diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingSeriesOrder.tsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingSeriesOrder.tsx index 2ce266ac9e3..19b6606f161 100644 --- a/frontend/src/metabase/visualizations/components/settings/ChartSettingSeriesOrder.tsx +++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingSeriesOrder.tsx @@ -4,11 +4,15 @@ import { useCallback, useMemo, useState } from "react"; import { t } from "ttag"; import _ from "underscore"; +import ColorSelector from "metabase/core/components/ColorSelector"; import type { DragEndEvent } from "metabase/core/components/Sortable"; +import { color } from "metabase/lib/colors"; +import { getAccentColors } from "metabase/lib/colors/groups"; import type { AccentColorOptions } from "metabase/lib/colors/types"; import { NULL_DISPLAY_VALUE } from "metabase/lib/constants"; +import { getEventTarget } from "metabase/lib/dom"; import { isEmpty } from "metabase/lib/validate"; -import { Button, Select } from "metabase/ui"; +import { Button, Flex, Group, Icon, Select, Text } from "metabase/ui"; import type { Series } from "metabase-types/api"; import { @@ -28,13 +32,14 @@ export interface SortableItem { name: string; color?: string; hidden?: boolean; + hideSettings?: boolean; } interface ChartSettingSeriesOrderProps { onChange: (rows: SortableItem[]) => void; value: SortableItem[]; onShowWidget: ( - widget: { props: { seriesKey: string } }, + widget: { id?: string; props?: { seriesKey: string } }, ref: HTMLElement | undefined, ) => void; series: Series; @@ -45,6 +50,11 @@ interface ChartSettingSeriesOrderProps { getItemColor?: (item: SortableChartSettingOrderedItem) => string | undefined; addButtonLabel?: string; searchPickerPlaceholder?: string; + groupedAfterIndex?: number; + otherColor?: string; + otherSettingWidgetId?: string; + onOtherColorChange?: (newColor: string) => void; + truncateAfter?: number; } export const ChartSettingSeriesOrder = ({ @@ -58,10 +68,16 @@ export const ChartSettingSeriesOrder = ({ onSortEnd, getItemColor, accentColorOptions, + otherColor, + groupedAfterIndex = Infinity, + otherSettingWidgetId, + truncateAfter = Infinity, + onOtherColorChange, }: ChartSettingSeriesOrderProps) => { + const [isListTruncated, setIsListTruncated] = useState<boolean>(true); const [isSeriesPickerVisible, setSeriesPickerVisible] = useState(false); - const [visibleItems, hiddenItems] = useMemo( + const [items, hiddenItems] = useMemo( () => _.partition( orderedItems.filter(item => !item.hidden), @@ -69,6 +85,27 @@ export const ChartSettingSeriesOrder = ({ ), [orderedItems], ); + const itemsAfterGrouping = useMemo(() => { + return items.map((item, index) => { + if (index < groupedAfterIndex) { + return item; + } + return { + ...item, + color: undefined, + hideSettings: true, + }; + }); + }, [groupedAfterIndex, items]); + + const [visibleItems, truncatedItems] = useMemo( + () => + _.partition( + itemsAfterGrouping, + (_item, index) => !isListTruncated || index < truncateAfter, + ), + [isListTruncated, itemsAfterGrouping, truncateAfter], + ); const canAddSeries = hiddenItems.length > 0; @@ -133,6 +170,52 @@ export const ChartSettingSeriesOrder = ({ const getId = useCallback((item: SortableItem) => item.key, []); + const handleOtherSeriesSettingsClick = useCallback( + (e: React.MouseEvent) => { + onShowWidget({ id: otherSettingWidgetId }, getEventTarget(e)); + }, + [onShowWidget, otherSettingWidgetId], + ); + + const dividers = useMemo(() => { + return [ + { + afterIndex: groupedAfterIndex, + renderFn: () => ( + <Flex justify="space-between" px={4}> + <Group p={4} spacing="sm"> + <ColorSelector + value={otherColor ?? color("text-light")} + colors={[ + ...getAccentColors(), + color("text-light"), + color("text-medium"), + color("text-dark"), + ]} + onChange={onOtherColorChange} + pillSize="small" + /> + <Text truncate fw="bold">{t`Other`}</Text> + </Group> + <Button + compact + color="text-medium" + variant="subtle" + leftIcon={<Icon name="gear" />} + aria-label={t`Other series settings`} + onClick={handleOtherSeriesSettingsClick} + /> + </Flex> + ), + }, + ]; + }, [ + groupedAfterIndex, + handleOtherSeriesSettingsClick, + onOtherColorChange, + otherColor, + ]); + return ( <ChartSettingOrderedSimpleRoot> {orderedItems.length > 0 ? ( @@ -149,7 +232,18 @@ export const ChartSettingSeriesOrder = ({ removeIcon="close" accentColorOptions={accentColorOptions} getItemColor={getItemColor} + dividers={dividers} /> + {truncatedItems.length > 0 ? ( + <div> + <Button + variant="subtle" + onClick={() => setIsListTruncated(false)} + > + {t`${truncatedItems.length} more series`} + </Button> + </div> + ) : null} {canAddSeries && !isSeriesPickerVisible && ( <Button variant="subtle" diff --git a/frontend/src/metabase/visualizations/components/settings/types.ts b/frontend/src/metabase/visualizations/components/settings/types.ts new file mode 100644 index 00000000000..4ea4317c698 --- /dev/null +++ b/frontend/src/metabase/visualizations/components/settings/types.ts @@ -0,0 +1,7 @@ +import type { VisualizationSettings } from "metabase-types/api"; + +export interface ChartSettingWidgetProps<TValue> { + value: TValue | undefined; + onChange: (value?: TValue | null) => void; + onChangeSettings: (settings: Partial<VisualizationSettings>) => void; +} diff --git a/frontend/src/metabase/visualizations/echarts/cartesian/constants/dataset.ts b/frontend/src/metabase/visualizations/echarts/cartesian/constants/dataset.ts index a8a1867f897..ca4cf5c3625 100644 --- a/frontend/src/metabase/visualizations/echarts/cartesian/constants/dataset.ts +++ b/frontend/src/metabase/visualizations/echarts/cartesian/constants/dataset.ts @@ -15,6 +15,9 @@ export const NEGATIVE_BAR_DATA_LABEL_KEY_SUFFIX = `${NULL_CHAR}_negative_bar_dat // Key of x-axis values export const X_AXIS_DATA_KEY = `${NULL_CHAR}_x` as const; +// Key for the "other" series created by the `graph.max_categories` setting +export const OTHER_DATA_KEY = `${NULL_CHAR}_other` as const; + // In some cases a datum in `chartModel.transformedDataset` may include this // key, its value is equal to the index of that same datum in the original // dataset (e.g. `chartModel.dataset`) diff --git a/frontend/src/metabase/visualizations/echarts/cartesian/model/dataset.ts b/frontend/src/metabase/visualizations/echarts/cartesian/model/dataset.ts index eafa4177d5d..e4ce18e2d27 100644 --- a/frontend/src/metabase/visualizations/echarts/cartesian/model/dataset.ts +++ b/frontend/src/metabase/visualizations/echarts/cartesian/model/dataset.ts @@ -8,6 +8,7 @@ import { ECHARTS_CATEGORY_AXIS_NULL_VALUE, NEGATIVE_STACK_TOTAL_DATA_KEY, ORIGINAL_INDEX_DATA_KEY, + OTHER_DATA_KEY, POSITIVE_STACK_TOTAL_DATA_KEY, X_AXIS_DATA_KEY, } from "metabase/visualizations/echarts/cartesian/constants/dataset"; @@ -47,6 +48,7 @@ import type { ShowWarning } from "../../types"; import { tryGetDate } from "../utils/timeseries"; import { isCategoryAxis, isNumericAxis, isTimeSeriesAxis } from "./guards"; +import { getAggregatedOtherSeriesValue } from "./other-series"; import { getBarSeriesDataLabelKey, getColumnScaling } from "./util"; /** @@ -334,6 +336,23 @@ const getStackedAreasInterpolateTransform = ( }; }; +function getOtherSeriesTransform( + groupedSeriesModels: SeriesModel[], + settings: ComputedVisualizationSettings, +): ConditionalTransform { + return { + condition: groupedSeriesModels.length > 0, + fn: datum => ({ + ...datum, + [OTHER_DATA_KEY]: getAggregatedOtherSeriesValue( + groupedSeriesModels, + settings["graph.other_category_aggregation_fn"], + datum, + ), + }), + }; +} + function getStackedValueTransformFunction( seriesDataKeys: DataKey[], valueTransform: (value: number) => number | null, @@ -697,6 +716,7 @@ export const applyVisualizationSettingsDataTransformations = ( stackModels: StackModel[], xAxisModel: XAxisModel, seriesModels: SeriesModel[], + groupedSeriesModels: SeriesModel[], yAxisScaleTransforms: NumericAxisScaleTransforms, settings: ComputedVisualizationSettings, showWarning?: ShowWarning, @@ -734,6 +754,7 @@ export const applyVisualizationSettingsDataTransformations = ( return transformDataset(dataset, [ getNullReplacerTransform(settings, seriesModels), + getOtherSeriesTransform(groupedSeriesModels, settings), { condition: settings["stackable.stack_type"] === "normalized", fn: getNormalizedDatasetTransform(stackModels), @@ -852,13 +873,12 @@ export const getSortedSeriesModels = ( seriesModel.vizSettingsKey === orderSetting.key && !usedDataKeys.has(seriesModel.dataKey), ); - if (foundSeries === undefined) { - throw new TypeError("Series not found"); + if (foundSeries) { + usedDataKeys.add(foundSeries.dataKey); } - - usedDataKeys.add(foundSeries.dataKey); return foundSeries; - }); + }) + .filter(isNotNull); // On stacked charts we reverse the order of series so that the series // order in the sidebar matches series order on the chart. 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 abc4303115d..defa3a8b2e7 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 @@ -303,6 +303,7 @@ describe("dataset transform functions", () => { [], xAxisModel, seriesModels, + [], yAxisScaleTransforms, createMockComputedVisualizationSettings({ "stackable.stack_type": "stacked", @@ -341,6 +342,7 @@ describe("dataset transform functions", () => { ], xAxisModel, seriesModels, + [], yAxisScaleTransforms, createMockComputedVisualizationSettings({ "stackable.stack_type": "normalized", @@ -380,6 +382,7 @@ describe("dataset transform functions", () => { [], xAxisModel, seriesModels, + [], yAxisScaleTransforms, createMockComputedVisualizationSettings({ series: (key: LegacySeriesSettingsObjectKey) => ({ @@ -434,6 +437,7 @@ describe("dataset transform functions", () => { [], xAxisModel, [createMockSeriesModel({ dataKey: "series1" })], + [], yAxisScaleTransforms, createMockComputedVisualizationSettings({ series: () => ({ @@ -465,6 +469,7 @@ describe("dataset transform functions", () => { [], { ...xAxisModel, intervalsCount: 10001 }, [createMockSeriesModel({ dataKey: "series1" })], + [], yAxisScaleTransforms, createMockComputedVisualizationSettings({ series: () => ({ @@ -511,6 +516,7 @@ describe("dataset transform functions", () => { [], xAxisModel, seriesModels, + [], yAxisScaleTransforms, createMockComputedVisualizationSettings(), ); @@ -530,6 +536,7 @@ describe("dataset transform functions", () => { [], xAxisModel, seriesModels, + [], yAxisScaleTransforms, createMockComputedVisualizationSettings(), ), @@ -543,6 +550,7 @@ describe("dataset transform functions", () => { [], xAxisModel, seriesModels, + [], yAxisScaleTransforms, createMockVisualizationSettings({ series: (key: LegacySeriesSettingsObjectKey) => ({ diff --git a/frontend/src/metabase/visualizations/echarts/cartesian/model/guards.ts b/frontend/src/metabase/visualizations/echarts/cartesian/model/guards.ts index 56f2f4c9d85..cea27c4b9e1 100644 --- a/frontend/src/metabase/visualizations/echarts/cartesian/model/guards.ts +++ b/frontend/src/metabase/visualizations/echarts/cartesian/model/guards.ts @@ -1,8 +1,8 @@ import type { + BaseSeriesModel, BreakoutSeriesModel, CategoryXAxisModel, NumericXAxisModel, - SeriesModel, TimeSeriesInterval, TimeSeriesXAxisModel, XAxisModel, @@ -27,7 +27,7 @@ export const isCategoryAxis = ( }; export const isBreakoutSeries = ( - seriesModel: SeriesModel, + seriesModel: BaseSeriesModel, ): seriesModel is BreakoutSeriesModel => { return "breakoutColumn" in seriesModel; }; diff --git a/frontend/src/metabase/visualizations/echarts/cartesian/model/index.ts b/frontend/src/metabase/visualizations/echarts/cartesian/model/index.ts index 63a6a581e4f..fbbb3ec8e62 100644 --- a/frontend/src/metabase/visualizations/echarts/cartesian/model/index.ts +++ b/frontend/src/metabase/visualizations/echarts/cartesian/model/index.ts @@ -1,3 +1,4 @@ +import { OTHER_DATA_KEY } from "metabase/visualizations/echarts/cartesian/constants/dataset"; import { getXAxisModel, getYAxesModels, @@ -28,6 +29,10 @@ import type { RawSeries, SingleSeries } from "metabase-types/api"; import type { ShowWarning } from "../../types"; +import { + createOtherGroupSeriesModel, + groupSeriesIntoOther, +} from "./other-series"; import { getStackModels } from "./stack"; import { getAxisTransforms } from "./transforms"; import { getTrendLines } from "./trend-line"; @@ -93,11 +98,6 @@ export const getCartesianChartModel = ( settings, ); - // We currently ignore sorting and visibility settings on combined cards - const seriesModels = hasMultipleCards - ? unsortedSeriesModels - : getSortedSeriesModels(unsortedSeriesModels, settings); - const unsortedDataset = getJoinedCardsDataset( rawSeries, cardsColumns, @@ -108,7 +108,27 @@ export const getCartesianChartModel = ( settings["graph.x_axis.scale"], showWarning, ); - const scaledDataset = scaleDataset(dataset, seriesModels, settings); + + const sortedSeriesModels = hasMultipleCards + ? unsortedSeriesModels + : getSortedSeriesModels(unsortedSeriesModels, settings); + + const scaledDataset = scaleDataset(dataset, sortedSeriesModels, settings); + + const { ungroupedSeriesModels: seriesModels, groupedSeriesModels } = + groupSeriesIntoOther(sortedSeriesModels, settings); + + const [sampleGroupedModel] = groupedSeriesModels; + if (sampleGroupedModel) { + seriesModels.push( + createOtherGroupSeriesModel( + sampleGroupedModel.column, + sampleGroupedModel.columnIndex, + settings, + !hiddenSeries.includes(OTHER_DATA_KEY), + ), + ); + } const xAxisModel = getXAxisModel( dimensionModel, @@ -128,6 +148,7 @@ export const getCartesianChartModel = ( stackModels, xAxisModel, seriesModels, + groupedSeriesModels, yAxisScaleTransforms, settings, showWarning, @@ -186,5 +207,6 @@ export const getCartesianChartModel = ( seriesLabelsFormatters, stackedLabelsFormatters, dataDensity, + groupedSeriesModels, }; }; diff --git a/frontend/src/metabase/visualizations/echarts/cartesian/model/legend.ts b/frontend/src/metabase/visualizations/echarts/cartesian/model/legend.ts index 792a155808a..d6c22da616c 100644 --- a/frontend/src/metabase/visualizations/echarts/cartesian/model/legend.ts +++ b/frontend/src/metabase/visualizations/echarts/cartesian/model/legend.ts @@ -1,8 +1,8 @@ import { isBreakoutSeries } from "./guards"; -import type { LegendItem, SeriesModel } from "./types"; +import type { BaseSeriesModel, LegendItem } from "./types"; export const getLegendItems = ( - seriesModels: SeriesModel[], + seriesModels: BaseSeriesModel[], showAllLegendItems: boolean = false, ): LegendItem[] => { if ( diff --git a/frontend/src/metabase/visualizations/echarts/cartesian/model/other-series.ts b/frontend/src/metabase/visualizations/echarts/cartesian/model/other-series.ts new file mode 100644 index 00000000000..063c5068a6f --- /dev/null +++ b/frontend/src/metabase/visualizations/echarts/cartesian/model/other-series.ts @@ -0,0 +1,154 @@ +import { t } from "ttag"; + +import { checkNumber } from "metabase/lib/types"; +import { isEmpty } from "metabase/lib/validate"; +import { SERIES_SETTING_KEY } from "metabase/visualizations/shared/settings/series"; +import type { ComputedVisualizationSettings } from "metabase/visualizations/types"; +import type { AggregationType, DatasetColumn } from "metabase-types/api"; + +import { OTHER_DATA_KEY } from "../constants/dataset"; + +import type { Datum, RegularSeriesModel, SeriesModel } from "./types"; + +export function groupSeriesIntoOther( + seriesModels: SeriesModel[], + settings: ComputedVisualizationSettings, +): { + ungroupedSeriesModels: SeriesModel[]; + groupedSeriesModels: SeriesModel[]; +} { + const maxCategories = settings["graph.max_categories"]; + + if ( + !settings["graph.max_categories_enabled"] || + !maxCategories || + maxCategories <= 0 || + seriesModels.length <= maxCategories + ) { + return { + ungroupedSeriesModels: seriesModels, + groupedSeriesModels: [], + }; + } + + const isReversed = !isEmpty(settings["stackable.stack_type"]); + const _seriesModels = isReversed ? seriesModels.toReversed() : seriesModels; + + const ungroupedSeriesModels = _seriesModels.slice( + 0, + settings["graph.max_categories"], + ); + if (isReversed) { + ungroupedSeriesModels.reverse(); + } + + const groupedSeriesModels = _seriesModels.slice( + settings["graph.max_categories"], + ); + + return { + ungroupedSeriesModels, + groupedSeriesModels, + }; +} + +export const createOtherGroupSeriesModel = ( + column: DatasetColumn, + columnIndex: number, + settings: ComputedVisualizationSettings, + isVisible: boolean, +): RegularSeriesModel => { + const customName = settings[SERIES_SETTING_KEY]?.[OTHER_DATA_KEY]?.title; + const name = customName ?? t`Other`; + + return { + name, + dataKey: OTHER_DATA_KEY, + color: settings["graph.other_category_color"], + visible: isVisible, + column, + columnIndex, + vizSettingsKey: OTHER_DATA_KEY, + legacySeriesSettingsObjectKey: { + card: { + _seriesKey: OTHER_DATA_KEY, + }, + }, + tooltipName: name, + }; +}; + +export const getAggregatedOtherSeriesValue = ( + seriesModels: SeriesModel[], + aggregationType: AggregationType = "sum", + datum: Datum, +): number => { + const aggregation = AGGREGATION_FN_MAP[aggregationType]; + const values = seriesModels.map(model => + checkNumber(datum[model.dataKey] ?? 0), + ); + return aggregation.fn(values); +}; + +export const getOtherSeriesAggregationLabel = ( + aggregationType: AggregationType = "sum", +) => AGGREGATION_FN_MAP[aggregationType].label; + +const sum = (values: number[]) => values.reduce((sum, value) => sum + value, 0); + +const AGGREGATION_FN_MAP: Record< + AggregationType, + { fn: (values: number[]) => number; label: string } +> = { + count: { + label: t`Total`, + fn: sum, + }, + sum: { + label: t`Total`, + fn: sum, + }, + "cum-sum": { + label: t`Total`, + fn: sum, + }, + "cum-count": { + label: t`Total`, + fn: sum, + }, + avg: { + label: t`Average`, + fn: values => sum(values) / values.length, + }, + distinct: { + label: t`Distinct values`, + fn: values => new Set(values).size, + }, + min: { + label: t`Min`, + fn: values => Math.min(...values), + }, + max: { + label: t`Max`, + fn: values => Math.max(...values), + }, + median: { + label: t`Median`, + fn: values => { + const sortedValues = values.sort((a, b) => a - b); + const middleIndex = Math.floor(sortedValues.length / 2); + return sortedValues.length % 2 + ? sortedValues[middleIndex] + : (sortedValues[middleIndex - 1] + sortedValues[middleIndex]) / 2; + }, + }, + stddev: { + label: t`Standard deviation`, + fn: values => { + const mean = sum(values) / values.length; + const squaredDifferences = values.map(v => (v - mean) ** 2); + const variance = sum(squaredDifferences) / values.length; + return Math.sqrt(variance); + }, + }, +}; diff --git a/frontend/src/metabase/visualizations/echarts/cartesian/model/series.ts b/frontend/src/metabase/visualizations/echarts/cartesian/model/series.ts index 4049a1c3f94..96a964e2982 100644 --- a/frontend/src/metabase/visualizations/echarts/cartesian/model/series.ts +++ b/frontend/src/metabase/visualizations/echarts/cartesian/model/series.ts @@ -1,5 +1,3 @@ -import _ from "underscore"; - import { NULL_DISPLAY_VALUE } from "metabase/lib/constants"; import { formatValue } from "metabase/lib/formatting"; import type { OptionsType } from "metabase/lib/formatting/types"; diff --git a/frontend/src/metabase/visualizations/echarts/cartesian/model/types.ts b/frontend/src/metabase/visualizations/echarts/cartesian/model/types.ts index 4c0984c3d13..770376fb172 100644 --- a/frontend/src/metabase/visualizations/echarts/cartesian/model/types.ts +++ b/frontend/src/metabase/visualizations/echarts/cartesian/model/types.ts @@ -234,6 +234,9 @@ export type BaseCartesianChartModel = { trendLinesModel?: TrendLinesModel; seriesLabelsFormatters: SeriesFormatters; + + // For `graph.max_categories` setting + groupedSeriesModels?: SeriesModel[]; }; export type CartesianChartModel = BaseCartesianChartModel & { diff --git a/frontend/src/metabase/visualizations/echarts/cartesian/option/index.ts b/frontend/src/metabase/visualizations/echarts/cartesian/option/index.ts index f75c987ae86..81530908709 100644 --- a/frontend/src/metabase/visualizations/echarts/cartesian/option/index.ts +++ b/frontend/src/metabase/visualizations/echarts/cartesian/option/index.ts @@ -3,6 +3,7 @@ import type { OptionSourceData } from "echarts/types/src/util/types"; import { NEGATIVE_STACK_TOTAL_DATA_KEY, + OTHER_DATA_KEY, POSITIVE_STACK_TOTAL_DATA_KEY, X_AXIS_DATA_KEY, } from "metabase/visualizations/echarts/cartesian/constants/dataset"; @@ -84,6 +85,7 @@ export const getCartesianChartOption = ( // dataset option const dimensions = [ X_AXIS_DATA_KEY, + OTHER_DATA_KEY, POSITIVE_STACK_TOTAL_DATA_KEY, NEGATIVE_STACK_TOTAL_DATA_KEY, ...chartModel.seriesModels.map(seriesModel => [ diff --git a/frontend/src/metabase/visualizations/echarts/cartesian/scatter/model/index.ts b/frontend/src/metabase/visualizations/echarts/cartesian/scatter/model/index.ts index 067877f2d34..79798ec4e1e 100644 --- a/frontend/src/metabase/visualizations/echarts/cartesian/scatter/model/index.ts +++ b/frontend/src/metabase/visualizations/echarts/cartesian/scatter/model/index.ts @@ -102,6 +102,7 @@ export function getScatterPlotModel( [], xAxisModel, seriesModels, + [], yAxisScaleTransforms, settings, showWarning, diff --git a/frontend/src/metabase/visualizations/lib/settings/graph.js b/frontend/src/metabase/visualizations/lib/settings/graph.js index d1541aa8bb6..404b2ce78bd 100644 --- a/frontend/src/metabase/visualizations/lib/settings/graph.js +++ b/frontend/src/metabase/visualizations/lib/settings/graph.js @@ -1,17 +1,16 @@ import { t } from "ttag"; import _ from "underscore"; +import { color } from "metabase/lib/colors"; import { getMaxDimensionsSupported, getMaxMetricsSupported, } from "metabase/visualizations"; +import { ChartSettingMaxCategories } from "metabase/visualizations/components/settings/ChartSettingMaxCategories"; import { ChartSettingSeriesOrder } from "metabase/visualizations/components/settings/ChartSettingSeriesOrder"; import { dimensionIsNumeric } from "metabase/visualizations/lib/numeric"; import { columnSettings } from "metabase/visualizations/lib/settings/column"; -import { - keyForSingleSeries, - seriesSetting, -} from "metabase/visualizations/lib/settings/series"; +import { seriesSetting } from "metabase/visualizations/lib/settings/series"; import { getOptionFromColumn } from "metabase/visualizations/lib/settings/utils"; import { dimensionIsTimeseries } from "metabase/visualizations/lib/timeseries"; import { MAX_SERIES, columnsAreValid } from "metabase/visualizations/lib/utils"; @@ -39,6 +38,7 @@ import { getDefaultYAxisTitle, getIsXAxisLabelEnabledDefault, getIsYAxisLabelEnabledDefault, + getSeriesModelsForSettings, getSeriesOrderDimensionSetting, getSeriesOrderVisibilitySettings, getYAxisAutoRangeDefault, @@ -66,6 +66,13 @@ function canHaveDataLabels(series, vizSettings) { return vizSettings["stackable.stack_type"] !== "normalized" || !areAllAreas; } +const areAllBars = (series, settings) => + getSeriesDisplays(series, settings).every(display => display === "bar"); + +const canHaveMaxCategoriesSetting = (series, settings) => { + return Boolean(series && areAllBars(series, settings) && series.length >= 2); +}; + export const GRAPH_DATA_SETTINGS = { ...columnSettings({ getColumns: ([ @@ -96,12 +103,18 @@ export const GRAPH_DATA_SETTINGS = { getDefault: (series, vizSettings) => getDefaultDimensions(series, vizSettings), persistDefault: true, - getProps: ([{ card, data }], vizSettings) => { + getProps: ([{ card, data }], vizSettings, _, { transformedSeries }) => { const addedDimensions = vizSettings["graph.dimensions"]; const maxDimensionsSupported = getMaxDimensionsSupported(card.display); const options = data.cols .filter(getDefaultDimensionFilter(card.display)) .map(getOptionFromColumn); + const fieldSettingWidgets = canHaveMaxCategoriesSetting( + transformedSeries, + vizSettings, + ) + ? [null, "graph.max_categories"] // We want to show "graph.max_categories" setting for the breakout dimension (2nd) + : []; return { options, addAnother: @@ -114,9 +127,7 @@ export const GRAPH_DATA_SETTINGS = { ? t`Add series breakout` : null, columns: data.cols, - // When this prop is passed it will only show the - // column settings for any index that is included in the array - showColumnSettingForIndicies: [0], + fieldSettingWidgets, }; }, writeDependencies: ["graph.metrics"], @@ -134,18 +145,45 @@ export const GRAPH_DATA_SETTINGS = { section: t`Data`, widget: ChartSettingSeriesOrder, marginBottom: "1rem", - - getValue: (series, settings) => { - const seriesKeys = series.map(s => keyForSingleSeries(s)); + useRawSeries: true, + getValue: (rawSeries, settings) => { + const seriesModels = getSeriesModelsForSettings(rawSeries, settings); + const seriesKeys = seriesModels.map(s => s.vizSettingsKey); return getSeriesOrderVisibilitySettings(settings, seriesKeys); }, - getHidden: (series, settings) => { + getProps: (rawSeries, settings, _onChange, _extra, onChangeSettings) => { + const groupedAfterIndex = + settings["graph.max_categories_enabled"] && + settings["graph.max_categories"] !== 0 + ? settings["graph.max_categories"] + : Infinity; + const onOtherColorChange = color => + onChangeSettings({ "graph.other_category_color": color }); + return { + rawSeries, + settings, + groupedAfterIndex, + otherColor: settings["graph.other_category_color"], + otherSettingWidgetId: "graph.max_categories", + onOtherColorChange, + truncateAfter: 10, + }; + }, + getHidden: (series, settings, { transformedSeries }) => { return ( - settings["graph.dimensions"]?.length < 2 || series.length > MAX_SERIES + settings["graph.dimensions"]?.length < 2 || + transformedSeries.length > MAX_SERIES ); }, dashboard: false, - readDependencies: ["series_settings.colors", "series_settings"], + readDependencies: [ + "series_settings.colors", + "series_settings", + "graph.metrics", + "graph.dimensions", + "graph.max_categories", + "graph.other_category_color", + ], writeDependencies: ["graph.series_order_dimension"], }, "graph.metrics": { @@ -421,6 +459,45 @@ export const GRAPH_DISPLAY_VALUES_SETTINGS = { }, default: getDefaultDataLabelsFormatting(), }, + "graph.max_categories_enabled": { + hidden: true, + getDefault: () => false, + isValid: (series, settings) => { + return canHaveMaxCategoriesSetting(series, settings); + }, + readDependencies: ["series_settings"], + }, + "graph.max_categories": { + widget: ChartSettingMaxCategories, + hidden: true, + default: 8, + isValid: (series, settings) => { + return canHaveMaxCategoriesSetting(series, settings); + }, + getProps: ([{ card }], settings) => { + return { + isEnabled: settings["graph.max_categories_enabled"], + aggregationFunction: settings["graph.other_category_aggregation_fn"], + }; + }, + readDependencies: [ + "graph.max_categories_enabled", + "graph.other_category_aggregation_fn", + "series_settings", + ], + }, + "graph.other_category_color": { + default: color("text-light"), + }, + "graph.other_category_aggregation_fn": { + hidden: true, + getDefault: ([{ data }], settings) => { + const [metricName] = settings["graph.metrics"]; + const metric = data.cols.find(col => col.name === metricName); + return metric?.aggregation_type ?? "sum"; + }, + readDependencies: ["graph.metrics"], + }, }; export const GRAPH_COLORS_SETTINGS = { diff --git a/frontend/src/metabase/visualizations/lib/settings/series.js b/frontend/src/metabase/visualizations/lib/settings/series.js index ad5c829106f..41bae587d64 100644 --- a/frontend/src/metabase/visualizations/lib/settings/series.js +++ b/frontend/src/metabase/visualizations/lib/settings/series.js @@ -2,6 +2,7 @@ import { getIn } from "icepick"; import { t } from "ttag"; import ChartNestedSettingSeries from "metabase/visualizations/components/settings/ChartNestedSettingSeries"; +import { OTHER_DATA_KEY } from "metabase/visualizations/echarts/cartesian/constants/dataset"; import { SERIES_COLORS_SETTING_KEY, SERIES_SETTING_KEY, @@ -59,6 +60,10 @@ export function seriesSetting({ readDependencies = [], def } = {}) { }, getDefault: (single, settings, { series }) => { + if (keyForSingleSeries(single) === OTHER_DATA_KEY) { + return "bar"; // "other" series is always a bar chart now + } + // FIXME: will move to Cartesian series model further, but now this code is used by other legacy charts const transformedSeriesIndex = series.findIndex( s => keyForSingleSeries(s) === keyForSingleSeries(single), diff --git a/frontend/src/metabase/visualizations/shared/settings/cartesian-chart.ts b/frontend/src/metabase/visualizations/shared/settings/cartesian-chart.ts index 3df9e4d8c73..6c96c496f60 100644 --- a/frontend/src/metabase/visualizations/shared/settings/cartesian-chart.ts +++ b/frontend/src/metabase/visualizations/shared/settings/cartesian-chart.ts @@ -7,6 +7,7 @@ import { getMaxMetricsSupported, } from "metabase/visualizations"; import { getCardsColumns } from "metabase/visualizations/echarts/cartesian/model"; +import { getCardsSeriesModels } from "metabase/visualizations/echarts/cartesian/model/series"; import { dimensionIsNumeric } from "metabase/visualizations/lib/numeric"; import { dimensionIsTimeseries } from "metabase/visualizations/lib/timeseries"; import { @@ -437,3 +438,11 @@ export function getComputedAdditionalColumnsValue( return filteredStoredColumns; } + +export function getSeriesModelsForSettings( + rawSeries: RawSeries, + settings: ComputedVisualizationSettings, +) { + const cardsColumns = getCardsColumns(rawSeries, settings); + return getCardsSeriesModels(rawSeries, cardsColumns, [], settings); +} diff --git a/frontend/src/metabase/visualizations/visualizations/CartesianChart/events.ts b/frontend/src/metabase/visualizations/visualizations/CartesianChart/events.ts index 9464fe3e307..d42b983e86c 100644 --- a/frontend/src/metabase/visualizations/visualizations/CartesianChart/events.ts +++ b/frontend/src/metabase/visualizations/visualizations/CartesianChart/events.ts @@ -22,6 +22,7 @@ import { import { formatValueForTooltip } from "metabase/visualizations/components/ChartTooltip/utils"; import { ORIGINAL_INDEX_DATA_KEY, + OTHER_DATA_KEY, X_AXIS_DATA_KEY, } from "metabase/visualizations/echarts/cartesian/constants/dataset"; import { @@ -29,8 +30,10 @@ import { isQuarterInterval, isTimeSeriesAxis, } from "metabase/visualizations/echarts/cartesian/model/guards"; +import { getOtherSeriesAggregationLabel } from "metabase/visualizations/echarts/cartesian/model/other-series"; import type { BaseCartesianChartModel, + BaseSeriesModel, ChartDataset, DataKey, Datum, @@ -205,7 +208,7 @@ const getEventColumnsData = ( const computeDiffWithPreviousPeriod = ( chartModel: BaseCartesianChartModel, - seriesIndex: number, + seriesModel: BaseSeriesModel, dataIndex: number, ): string | null => { if (!isTimeSeriesAxis(chartModel.xAxisModel)) { @@ -213,7 +216,6 @@ const computeDiffWithPreviousPeriod = ( } const datum = chartModel.dataset[dataIndex]; - const seriesModel = chartModel.seriesModels[seriesIndex]; const currentValue = datum[seriesModel.dataKey]; const currentDate = parseTimestamp(datum[X_AXIS_DATA_KEY]); @@ -315,30 +317,6 @@ export const getSeriesHovered = ( }; }; -export const getSeriesHoverData = ( - chartModel: BaseCartesianChartModel, - settings: ComputedVisualizationSettings, - echartsDataIndex: number, - seriesId: DataKey, -) => { - const dataIndex = getDataIndex( - chartModel.transformedDataset, - echartsDataIndex, - ); - const seriesIndex = findSeriesModelIndexById(chartModel, seriesId); - - if (seriesIndex < 0 || dataIndex == null) { - return; - } - - return { - settings, - isAlreadyScaled: true, - index: seriesIndex, - datumIndex: dataIndex, - }; -}; - const getAdditionalTooltipRowsData = ( chartModel: BaseCartesianChartModel, settings: ComputedVisualizationSettings, @@ -384,11 +362,21 @@ export const getTooltipModel = ( if (dataIndex == null) { return null; } + const datum = chartModel.dataset[dataIndex]; - const seriesIndex = chartModel.seriesModels.findIndex( - seriesModel => seriesModel.dataKey === seriesDataKey, + + if (seriesDataKey === OTHER_DATA_KEY) { + return getOtherSeriesTooltipModel(chartModel, settings, dataIndex, datum); + } + + const hoveredSeries = chartModel.seriesModels.find( + s => s.dataKey === seriesDataKey, ); - const hoveredSeries = chartModel.seriesModels[seriesIndex]; + + if (!hoveredSeries) { + return null; + } + const seriesStack = chartModel.stackModels.find(stackModel => stackModel.seriesKeys.includes(hoveredSeries.dataKey), ); @@ -416,7 +404,6 @@ export const getTooltipModel = ( hoveredSeries, ); } - return getSeriesOnlyTooltipModel( chartModel, settings, @@ -490,15 +477,19 @@ export const getSeriesOnlyTooltipModel = ( const seriesRows: EChartsTooltipRow[] = chartModel.seriesModels .filter(seriesModel => seriesModel.visible) - .map((seriesModel, seriesIndex) => { + .map(seriesModel => { const isHoveredSeries = seriesModel.dataKey === hoveredSeries.dataKey; const isFocused = isHoveredSeries && chartModel.seriesModels.length > 1; - const prevValue = computeDiffWithPreviousPeriod( - chartModel, - seriesIndex, - dataIndex, - ); + const value = + seriesModel.dataKey === OTHER_DATA_KEY + ? chartModel.transformedDataset[dataIndex][OTHER_DATA_KEY] + : datum[seriesModel.dataKey]; + + const prevValue = + seriesModel.dataKey === OTHER_DATA_KEY + ? null + : computeDiffWithPreviousPeriod(chartModel, seriesModel, dataIndex); return { isFocused, @@ -508,13 +499,13 @@ export const getSeriesOnlyTooltipModel = ( ), values: [ formatValueForTooltip({ - value: datum[seriesModel.dataKey], + value: value, column: seriesModel.column, settings, isAlreadyScaled: true, }), prevValue, - ], + ].filter(isNotNull), }; }); @@ -570,12 +561,18 @@ export const getStackedTooltipModel = ( seriesStack?.seriesKeys.includes(seriesModel.dataKey), ) .map(seriesModel => { + const datum = chartModel.dataset[dataIndex]; + const value = + seriesModel.dataKey === OTHER_DATA_KEY + ? chartModel.transformedDataset[dataIndex][OTHER_DATA_KEY] + : datum[seriesModel.dataKey]; + return { isFocused: seriesModel.dataKey === seriesDataKey, name: seriesModel.name, color: seriesModel.color, - value: chartModel.dataset[dataIndex][seriesModel.dataKey], dataKey: seriesModel.dataKey, + value, }; }); @@ -647,6 +644,74 @@ export const getStackedTooltipModel = ( }; }; +export const getOtherSeriesTooltipModel = ( + chartModel: BaseCartesianChartModel, + settings: ComputedVisualizationSettings, + dataIndex: number, + datum: Datum, +) => { + const { groupedSeriesModels = [] } = chartModel; + + const rows = groupedSeriesModels + .map(seriesModel => ({ + name: seriesModel.name, + column: seriesModel.column, + value: datum[seriesModel.dataKey], + prevValue: computeDiffWithPreviousPeriod( + chartModel, + seriesModel, + dataIndex, + ), + })) + .sort((a, b) => { + if (typeof a.value === "number" && typeof b.value === "number") { + return b.value - a.value; + } + return a.value === undefined ? 1 : -1; + }) + .map(row => ({ + name: row.name, + values: [ + formatValueForTooltip({ + value: row.value, + column: row.column, + isAlreadyScaled: true, + settings, + }), + row.prevValue, + ], + })); + + rows.push({ + name: getOtherSeriesAggregationLabel( + settings["graph.other_category_aggregation_fn"], + ), + values: [ + String( + formatValueForTooltip({ + isAlreadyScaled: true, + value: chartModel.transformedDataset[dataIndex][OTHER_DATA_KEY], + settings, + column: + chartModel.leftAxisModel?.column ?? + chartModel.rightAxisModel?.column, + }), + ), + ], + }); + + return { + header: String( + formatValueForTooltip({ + value: datum[X_AXIS_DATA_KEY], + column: chartModel.dimensionModel.column, + settings, + }), + ), + rows, + }; +}; + export const getTimelineEventsForEvent = ( timelineEventsModel: TimelineEventsModel, event: EChartsSeriesMouseEvent, @@ -720,7 +785,11 @@ export const getSeriesClickData = ( const seriesIndex = findSeriesModelIndexById(chartModel, seriesId); const seriesModel = chartModel.seriesModels[seriesIndex]; - if (seriesIndex < 0 || dataIndex == null) { + if ( + seriesIndex < 0 || + dataIndex == null || + seriesModel?.dataKey === OTHER_DATA_KEY + ) { return; } -- GitLab