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 Binary files /dev/null and b/.loki/reference/chrome_laptop_static_viz_ComboChart_Bar_Max_Categories_Default.png differ 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 Binary files /dev/null and b/.loki/reference/chrome_laptop_static_viz_ComboChart_Bar_Max_Categories_Stacked.png differ 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 Binary files /dev/null and b/.loki/reference/chrome_laptop_static_viz_ComboChart_Bar_Max_Categories_Stacked_Normalized.png differ diff --git a/e2e/support/helpers/e2e-visual-tests-helpers.js b/e2e/support/helpers/e2e-visual-tests-helpers.js index 916f0f475ac22534a973267f79a77ad816ff71e6..270f46e3623de24dedd0d23fab00de739e79c31c 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 ba06be055aa6b9ea4ea5fb01cfa5746151e215a8..baa5338c876d22a73638fad77c5b44c479566be5 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 b2e072515390173187c5a3b36b633b218362aec0..babfb5c17ef56b9796a5c600894bae833ebc533e 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 51e7801e79273b89b200d4b9f48edbf2684a35e0..a6125b49b1ee2a53ed06648bc46ed98fb9c8252c 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 eb69a9e1416c0ab8dac616deb64c8dae8712c554..91c72702d14732c2e6a7af54e12c94bb9093a7e7 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 f99d70299bd89e2798a5593c7ddce39751b4a008..80f820e9668c4b7c6b0996222aa70de71e2d8e43 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 733b3873499fc590be6d770337c84f1def093b1a..d771e36ea13cfe186b41d45669ecfb6a488cebc1 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 0000000000000000000000000000000000000000..fe50cdadb550a0bb7b6b93f02e45d389496c2086 --- /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 0000000000000000000000000000000000000000..5c9bd15df42f3352b4a5b60e57453d1c97c69d55 --- /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 0000000000000000000000000000000000000000..1dd35791d4954328b261fd884871c78ea4cb09ef --- /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 d6a3293f551c9dfcdaefe263144012774a3d0fa6..89c4f17c37dc82b39ab033ebf3fee8fa5501bdf4 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 a6ba65c316c539b44deef5f30184743f9951f179..3e4f33e2c4799af3b19f71b3c26e80e725d5040a 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 7d874c2e1fbc667b150f8983d468020d36222889..8e5306cc36576eb24633ffbc34c373562a5dc820 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 b45bbc68640e061461122a5470c0dcc3b30f091c..dd85bd52d421d0f2e921e87a0e0bd56c68407133 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 69811949a1b9b689e42f6729f7f2878f91490275..ec825a751d1f504833cfce55ad1ba0bdcae701ed 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 80eae16ce277c9447fec736e5b459ebbb1c485f6..5e382e368d3369cec9b30fd15f2949dba20385b6 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 8caa8715de0f8d1a02d668ae01f3cff871e1ad6b..2e32f7fa5f26bf3a5a8a6af688b90099b496c8ae 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 7616531c2dbdd5508f1906b86ff4c326aa3444d3..ef81f1493ce72ec14b24bd67ad701d10afb13245 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 f5ffdaa3c3ee6cf92683b85414be840b31d8d6fc..79fc927e22ea2fd6035a0baeee05ac7a2f23db15 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 f692dc2bb7ec1510ae27a2738d8516a90ef67e16..715af5d8c670b5d667f299435e7ad7e0d7ec8a2e 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 0000000000000000000000000000000000000000..9dd8d0fde875deb7d7dc7000e037e7ed8c9ad22e --- /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 6869dd3b4d018956fbcc0cdeb1e5de421e493836..1e78b76f9994154169840a71111d2cc5fe1d80ec 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 2ce266ac9e30332a49136f60d54c7858bcc6d344..19b6606f161e0d9dd9300e10718cc12c66bea95d 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 0000000000000000000000000000000000000000..4ea4317c6983b00615281d71b92eb442c61e2010 --- /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 a8a1867f897ca77a93d5fe05124c1b5b5fab31ed..ca4cf5c3625691c595fe2e34a0ee5de37fedb0a7 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 eafa4177d5d50bf0d6a8e06e42b06ad097f32329..e4ce18e2d2727d2b370e555c7eb78eb1f80073dd 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 abc4303115d1bc37da4c0c1ea549cc758bc13e80..defa3a8b2e747cae742664123b6673c1d4bd91b4 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 56f2f4c9d85fd6c7c1a7530b077461bd452b63fa..cea27c4b9e10fa7867f27329661a52dbcdc4e78b 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 63a6a581e4f7e5d5ae7eeda36457239427895abd..fbbb3ec8e6231becf076f394f498eb03c70694c6 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 792a155808afdf7d15a57b60836752cd0e01196f..d6c22da616c0427f53086c067563a7b80157f4f6 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 0000000000000000000000000000000000000000..063c5068a6f4ba440abafffea046a02c7c25354d --- /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 4049a1c3f94d7273a490bae1b46c525af120e959..96a964e2982b9e9ca69c60c1450b50e009639b72 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 4c0984c3d135ef65391c96c37755250515964112..770376fb172cbd5b874a87b6d177d04d6d061a37 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 f75c987ae8622bfe8c508e85a2e73eb2b0caac93..815309087095895384de5e29203fe0e0380795c1 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 067877f2d3472f5c5ceba6f3ab099d9cf846df8c..79798ec4e1eda8ec8d83b6e5c6da2568a5dc3f0b 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 d1541aa8bb679c577913d25fefbec68022ceeee0..404b2ce78bd1e0e9becad3e4dc8d6e58f3550198 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 ad5c829106ffeae2cf5d86cfdbb92bc547c50780..41bae587d643d044f62a496b96504a0d38f041f0 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 3df9e4d8c732cec06dc7f6e448e84bca3b83a9ab..6c96c496f6042e058e2733f3795f02f007628685 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 9464fe3e3078aedfe7f5500fc506fa1ce1af9e23..d42b983e86cd71e584dbced70476121531e52cf1 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; }