diff --git a/.loki/reference/chrome_laptop_static_viz_PieChart_All_Negative.png b/.loki/reference/chrome_laptop_static_viz_PieChart_All_Negative.png index 1b4c21eb7e319abe939e118a6ca4a60b2a97d8a0..4b30ff2502363cce7b8e4d24ea7a15aa41b7ce82 100644 Binary files a/.loki/reference/chrome_laptop_static_viz_PieChart_All_Negative.png and b/.loki/reference/chrome_laptop_static_viz_PieChart_All_Negative.png differ diff --git a/.loki/reference/chrome_laptop_static_viz_PieChart_Labels_On_Chart.png b/.loki/reference/chrome_laptop_static_viz_PieChart_Labels_On_Chart.png new file mode 100644 index 0000000000000000000000000000000000000000..c36e5f5bc4398902ea4d57e7805301f7bb61ffce Binary files /dev/null and b/.loki/reference/chrome_laptop_static_viz_PieChart_Labels_On_Chart.png differ diff --git a/.loki/reference/chrome_laptop_static_viz_PieChart_Labels_With_Percent.png b/.loki/reference/chrome_laptop_static_viz_PieChart_Labels_With_Percent.png new file mode 100644 index 0000000000000000000000000000000000000000..d19eced54933cd8a20a0d9e38ad8e497008b01c1 Binary files /dev/null and b/.loki/reference/chrome_laptop_static_viz_PieChart_Labels_With_Percent.png differ diff --git a/.loki/reference/chrome_laptop_static_viz_PieChart_Three_Rings.png b/.loki/reference/chrome_laptop_static_viz_PieChart_Three_Rings.png new file mode 100644 index 0000000000000000000000000000000000000000..abfafe0d42894fdeec4ced1e2b8033d7a86e07f7 Binary files /dev/null and b/.loki/reference/chrome_laptop_static_viz_PieChart_Three_Rings.png differ diff --git a/.loki/reference/chrome_laptop_static_viz_PieChart_Three_Rings_No_Labels.png b/.loki/reference/chrome_laptop_static_viz_PieChart_Three_Rings_No_Labels.png new file mode 100644 index 0000000000000000000000000000000000000000..92f375f508a94cd881d83ad9900117895969b49b Binary files /dev/null and b/.loki/reference/chrome_laptop_static_viz_PieChart_Three_Rings_No_Labels.png differ diff --git a/.loki/reference/chrome_laptop_static_viz_PieChart_Three_Rings_Other_Slices.png b/.loki/reference/chrome_laptop_static_viz_PieChart_Three_Rings_Other_Slices.png new file mode 100644 index 0000000000000000000000000000000000000000..980ac6b5949d4674110a322f07d495580073f220 Binary files /dev/null and b/.loki/reference/chrome_laptop_static_viz_PieChart_Three_Rings_Other_Slices.png differ diff --git a/.loki/reference/chrome_laptop_static_viz_PieChart_Truncated_Total.png b/.loki/reference/chrome_laptop_static_viz_PieChart_Truncated_Total.png index 98b50c8db0672b1762b544d4c58042cf872c8e76..df7fac840d8abce3c417ad60a85bc701b5844610 100644 Binary files a/.loki/reference/chrome_laptop_static_viz_PieChart_Truncated_Total.png and b/.loki/reference/chrome_laptop_static_viz_PieChart_Truncated_Total.png differ diff --git a/.loki/reference/chrome_laptop_static_viz_PieChart_Two_Rings.png b/.loki/reference/chrome_laptop_static_viz_PieChart_Two_Rings.png new file mode 100644 index 0000000000000000000000000000000000000000..8a1f853ed81875e77415992bb59c5644db383474 Binary files /dev/null and b/.loki/reference/chrome_laptop_static_viz_PieChart_Two_Rings.png differ diff --git a/e2e/test/scenarios/visualizations-charts/pie_chart.cy.spec.js b/e2e/test/scenarios/visualizations-charts/pie_chart.cy.spec.js index dd337f324d3aac2419d1cdba37559e1ad9b1c5ec..b7c4d176ff3cf31f092014869933a8b42f45e21b 100644 --- a/e2e/test/scenarios/visualizations-charts/pie_chart.cy.spec.js +++ b/e2e/test/scenarios/visualizations-charts/pie_chart.cy.spec.js @@ -1,7 +1,9 @@ import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; import { + assertEChartsTooltip, chartPathWithFillColor, + echartsContainer, getDraggableElements, getNotebookStep, leftSidebar, @@ -15,7 +17,7 @@ import { visualize, } from "e2e/support/helpers"; -const { PRODUCTS, PRODUCTS_ID } = SAMPLE_DATABASE; +const { PRODUCTS, PRODUCTS_ID, ORDERS_ID, ORDERS, PEOPLE } = SAMPLE_DATABASE; const testQuery = { type: "query", @@ -27,6 +29,53 @@ const testQuery = { database: SAMPLE_DB_ID, }; +const twoRingQuery = { + database: 1, + type: "query", + query: { + "source-table": ORDERS_ID, + aggregation: [["count"]], + breakout: [ + [ + "field", + ORDERS.CREATED_AT, + { "base-type": "type/DateTime", "temporal-unit": "day-of-week" }, + ], + [ + "field", + PRODUCTS.CATEGORY, + { "base-type": "type/Text", "source-field": ORDERS.PRODUCT_ID }, + ], + ], + }, +}; + +const threeRingQuery = { + database: 1, + type: "query", + query: { + "source-table": ORDERS_ID, + aggregation: [["count"]], + breakout: [ + [ + "field", + ORDERS.CREATED_AT, + { "base-type": "type/DateTime", "temporal-unit": "year" }, + ], + [ + "field", + PEOPLE.SOURCE, + { "base-type": "type/Text", "source-field": ORDERS.USER_ID }, + ], + [ + "field", + PRODUCTS.CATEGORY, + { "base-type": "type/Text", "source-field": ORDERS.PRODUCT_ID }, + ], + ], + }, +}; + describe("scenarios > visualizations > pie chart", () => { beforeEach(() => { restore(); @@ -39,7 +88,12 @@ describe("scenarios > visualizations > pie chart", () => { display: "pie", }); - ensurePieChartRendered(["Doohickey", "Gadget", "Gizmo", "Widget"], 200); + ensurePieChartRendered( + ["Doohickey", "Gadget", "Gizmo", "Widget"], + null, + null, + 200, + ); // chart should be centered (#48123) cy.findByTestId("chart-legend").then(([legend]) => { @@ -230,9 +284,215 @@ describe("scenarios > visualizations > pie chart", () => { cy.get("li").eq(3).contains("Woooget"); }); }); + + it("should automatically map dimension columns in query to rings", () => { + visitQuestionAdhoc({ + dataset_query: twoRingQuery, + display: "pie", + }); + + ensurePieChartRendered( + [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + ], + ["Doohickey", "Gadget", "Gizmo", "Widget"], + ); + }); + + it("should allow the user to edit rings", () => { + visitQuestionAdhoc({ + dataset_query: threeRingQuery, + display: "pie", + visualization_settings: { + "pie.slice_threshold": 0, + }, + }); + + ensurePieChartRendered( + ["2022", "2023", "2024", "2025", "2026"], + ["Affiliate", "Facebook", "Google", "Organic", "Twitter"], + ["Doohickey", "Gadget", "Gizmo", "Widget"], + ); + + cy.findByTestId("viz-settings-button").click(); + + cy.findAllByTestId("chartsettings-field-picker") + .last() + .within(() => { + cy.icon("close").click(); + }); + + ensurePieChartRendered( + ["2022", "2023", "2024", "2025", "2026"], + ["Affiliate", "Facebook", "Google", "Organic", "Twitter"], + ); + + cy.findAllByTestId("chartsettings-field-picker") + .last() + .within(() => { + cy.icon("chevrondown").click(); + }); + + cy.get("[data-element-id=list-section]").last().click(); + + ensurePieChartRendered( + ["2022", "2023", "2024", "2025", "2026"], + ["Doohickey", "Gadget", "Gizmo", "Widget"], + ); + + leftSidebar().within(() => { + cy.findByText("Add Ring").click(); + }); + + cy.get("[data-element-id=list-section]").last().click(); + + ensurePieChartRendered( + ["2022", "2023", "2024", "2025", "2026"], + ["Doohickey", "Gadget", "Gizmo", "Widget"], + ["Affiliate", "Facebook", "Google", "Organic", "Twitter"], + ); + }); + + it("should handle hover and click actions correctly", () => { + visitQuestionAdhoc({ + dataset_query: twoRingQuery, + display: "pie", + visualization_settings: { + "pie.slice_threshold": 0, + }, + }); + + ensurePieChartRendered( + [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + ], + ["Doohickey", "Gadget", "Gizmo", "Widget"], + ); + + echartsContainer().within(() => { + cy.findByText("Saturday").as("saturdaySlice").trigger("mousemove"); + }); + + assertEChartsTooltip({ + header: "Created At", + rows: [ + { + color: "#51528D", + name: "Saturday", + value: "2,747", + }, + { + color: "#ED8535", + name: "Thursday", + value: "2,698", + }, + { + color: "#E75454", + name: "Tuesday", + value: "2,695", + }, + { + color: "#689636", + name: "Sunday", + value: "2,671", + }, + { + color: "#8A5EB0", + name: "Monday", + value: "2,664", + }, + { + color: "#69C8C8", + name: "Friday", + value: "2,662", + }, + { + color: "#F7C41F", + name: "Wednesday", + value: "2,623", + }, + ], + }); + + cy.get("@saturdaySlice").click({ force: true }); + + popover().within(() => { + cy.findByText("=").click(); + }); + + cy.findByTestId("qb-filters-panel").within(() => { + cy.findByText("Count is equal to 2747").should("be.visible"); + }); + + cy.go("back"); + + ensurePieChartRendered( + [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + ], + ["Doohickey", "Gadget", "Gizmo", "Widget"], + ); + + echartsContainer().within(() => { + cy.findAllByText("Doohickey") + .first() + .as("doohickeySlice") + .trigger("mousemove"); + }); + + assertEChartsTooltip({ + header: "Saturday", + rows: [ + { + name: "Doohickey", + value: "606", + }, + { + name: "Gadget", + value: "740", + }, + { + name: "Gizmo", + value: "640", + }, + { + name: "Widget", + value: "761", + }, + ], + }); + + cy.get("@doohickeySlice").click({ force: true }); + + popover().within(() => { + cy.findByText("=").click(); + }); + + cy.findByTestId("qb-filters-panel").within(() => { + cy.findByText("Count is equal to 606").should("be.visible"); + }); + }); }); -function ensurePieChartRendered(rows, totalValue) { +function ensurePieChartRendered(rows, middleRows, outerRows, totalValue) { cy.findByTestId("query-visualization-root").within(() => { // detail if (totalValue != null) { @@ -241,7 +501,17 @@ function ensurePieChartRendered(rows, totalValue) { } // slices - pieSlices().should("have.length", rows.length); + let rowCount = rows.length; + const hasMiddleRows = middleRows != null && middleRows.length > 0; + const hasOuterRows = outerRows != null && outerRows.length > 0; + + if (hasMiddleRows) { + rowCount += rows.length * middleRows.length; + } + if (hasMiddleRows && hasOuterRows) { + rowCount += rows.length * middleRows.length * outerRows.length; + } + pieSlices().should("have.length", rowCount); // legend rows.forEach((name, i) => { diff --git a/frontend/src/metabase-types/api/card.ts b/frontend/src/metabase-types/api/card.ts index dca7425e13fb98ad0d9ac6587ca76095869faccb..f0f646bf3c08375f7f5533fb115fe0b0d1f9efae 100644 --- a/frontend/src/metabase-types/api/card.ts +++ b/frontend/src/metabase-types/api/card.ts @@ -214,12 +214,15 @@ export type VisualizationSettings = { "scalar.compact_primary_number"?: boolean; // Pie Settings - "pie.dimension"?: string; + "pie.dimension"?: string | string[]; + "pie.middle_dimension"?: string; + "pie.outer_dimension"?: string; "pie.rows"?: PieRow[]; "pie.metric"?: string; "pie.sort_rows"?: boolean; "pie.show_legend"?: boolean; "pie.show_total"?: boolean; + "pie.show_labels"?: boolean; "pie.percent_visibility"?: "off" | "legend" | "inside" | "both"; "pie.decimal_places"?: number; "pie.slice_threshold"?: number; diff --git a/frontend/src/metabase/core/components/Sortable/SortableList.tsx b/frontend/src/metabase/core/components/Sortable/SortableList.tsx index 949fa96cf350a9a354c14ec9e6d6c271e05446a3..eb69a9e1416c0ab8dac616deb64c8dae8712c554 100644 --- a/frontend/src/metabase/core/components/Sortable/SortableList.tsx +++ b/frontend/src/metabase/core/components/Sortable/SortableList.tsx @@ -24,7 +24,7 @@ export type RenderItemProps<T> = { id: ItemId; isDragOverlay?: boolean; }; -type useSortableListProps<T> = { +type SortableListProps<T> = { items: T[]; getId: (item: T) => ItemId; renderItem: ({ @@ -48,7 +48,7 @@ export const SortableList = <T,>({ sensors = [], modifiers = [], useDragOverlay = true, -}: useSortableListProps<T>) => { +}: SortableListProps<T>) => { const [itemIds, setItemIds] = useState<ItemId[]>([]); const [indexedItems, setIndexedItems] = useState<Partial<Record<ItemId, T>>>( {}, diff --git a/frontend/src/metabase/lib/colors/palette.ts b/frontend/src/metabase/lib/colors/palette.ts index 0570fd81441835507b3ffe43ab0b31234b8dcaf5..50ad67c13f88c5c27496e003c829246b91daadf8 100644 --- a/frontend/src/metabase/lib/colors/palette.ts +++ b/frontend/src/metabase/lib/colors/palette.ts @@ -55,7 +55,7 @@ export const colors = { export const originalColors = { ...colors }; -const aliases: Record<string, (palette: ColorPalette) => string> = { +export const aliases: Record<string, (palette: ColorPalette) => string> = { dashboard: palette => color("brand", palette), nav: palette => color("bg-white", palette), content: palette => color("bg-light", palette), diff --git a/frontend/src/metabase/static-viz/components/PieChart/PieChart.stories.tsx b/frontend/src/metabase/static-viz/components/PieChart/PieChart.stories.tsx index 3719b3b3f0944b97a57ca3217ee3e963cda0bf44..1397357a23cbf7f7603b3af4bd820cb5a99f0238 100644 --- a/frontend/src/metabase/static-viz/components/PieChart/PieChart.stories.tsx +++ b/frontend/src/metabase/static-viz/components/PieChart/PieChart.stories.tsx @@ -405,3 +405,39 @@ export const MissingLabelLargeSlice38424 = { renderingContext, }, }; + +export const TwoRings = Template.bind({}); +TwoRings.args = { + rawSeries: data.twoRings as any, + renderingContext, +}; + +export const ThreeRings = Template.bind({}); +ThreeRings.args = { + rawSeries: data.threeRings as any, + renderingContext, +}; + +export const ThreeRingsNoLabels = Template.bind({}); +ThreeRingsNoLabels.args = { + rawSeries: data.threeRingsNoLabels as any, + renderingContext, +}; + +export const ThreeRingsOtherSlices = Template.bind({}); +ThreeRingsOtherSlices.args = { + rawSeries: data.threeRingsOtherSlices as any, + renderingContext, +}; + +export const LabelsWithPercent = Template.bind({}); +LabelsWithPercent.args = { + rawSeries: data.labelsWithPercent as any, + renderingContext, +}; + +export const LabelsOnChart = Template.bind({}); +LabelsOnChart.args = { + rawSeries: data.labelsOnChart as any, + renderingContext, +}; diff --git a/frontend/src/metabase/static-viz/components/PieChart/legend.tsx b/frontend/src/metabase/static-viz/components/PieChart/legend.tsx index 06b39fe24dbcbe4b1ed7c3782c87b08e964ee831..36a200a2dcd7b285f3abd90e74f0f690bd9eac78 100644 --- a/frontend/src/metabase/static-viz/components/PieChart/legend.tsx +++ b/frontend/src/metabase/static-viz/components/PieChart/legend.tsx @@ -1,6 +1,7 @@ import { DIMENSIONS } from "metabase/visualizations/echarts/pie/constants"; import type { PieChartFormatters } from "metabase/visualizations/echarts/pie/format"; import type { PieChartModel } from "metabase/visualizations/echarts/pie/model/types"; +import { getArrayFromMapValues } from "metabase/visualizations/echarts/pie/util"; import type { ComputedVisualizationSettings } from "metabase/visualizations/types"; import { Legend } from "../Legend"; @@ -17,18 +18,20 @@ export function getPieChartLegend( width: legendWidth, items, } = calculateLegendRowsWithColumns({ - items: chartModel.slices - .filter(s => s.data.includeInLegend) - .map(s => ({ - name: s.data.name, - percent: - settings["pie.percent_visibility"] === "legend" || - settings["pie.percent_visibility"] === "both" - ? formatters.formatPercent(s.data.normalizedPercentage, "legend") - : undefined, - color: s.data.color, - key: String(s.data.key), - })), + items: getArrayFromMapValues(chartModel.sliceTree) + .filter(s => s.includeInLegend) + .map(s => { + return { + name: s.name, + percent: + settings["pie.percent_visibility"] === "legend" || + settings["pie.percent_visibility"] === "both" + ? formatters.formatPercent(s.normalizedPercentage, "legend") + : undefined, + color: s.color, + key: String(s.key), + }; + }), width: DIMENSIONS.maxSideLength, horizontalPadding: DIMENSIONS.padding.side, }); diff --git a/frontend/src/metabase/static-viz/components/PieChart/settings.ts b/frontend/src/metabase/static-viz/components/PieChart/settings.ts index 36fbac7761a3a6869cfe020dd1c19a32336dfd73..72178c1d32433281699493950029575183099530 100644 --- a/frontend/src/metabase/static-viz/components/PieChart/settings.ts +++ b/frontend/src/metabase/static-viz/components/PieChart/settings.ts @@ -3,13 +3,12 @@ import { fillWithDefaultValue, getCommonStaticVizSettings, } from "metabase/static-viz/lib/settings"; -import { - columnsAreValid, - getDefaultDimensionAndMetric, -} from "metabase/visualizations/lib/utils"; +import { columnsAreValid } from "metabase/visualizations/lib/utils"; import { getColors, getDefaultPercentVisibility, + getDefaultPieColumns, + getDefaultShowLabels, getDefaultShowLegend, getDefaultShowTotal, getDefaultSliceThreshold, @@ -23,25 +22,20 @@ export function computeStaticPieChartSettings( rawSeries: RawSeries, ): ComputedVisualizationSettings { const settings = getCommonStaticVizSettings(rawSeries); - const { dimension: defaultDimension, metric: defaultMetric } = - getDefaultDimensionAndMetric(rawSeries); - - const dimensionIsValid = columnsAreValid( - settings["pie.dimension"], - rawSeries[0].data, - ); - const metricIsValid = columnsAreValid( - settings["pie.metric"], - rawSeries[0].data, + const defaultColumns = getDefaultPieColumns(rawSeries); + fillWithDefaultValue( + settings, + "pie.dimension", + defaultColumns.dimension, + columnsAreValid(settings["pie.dimension"], rawSeries[0].data), ); fillWithDefaultValue( settings, - "pie.dimension", - defaultDimension, - dimensionIsValid, + "pie.metric", + defaultColumns.metric, + columnsAreValid(settings["pie.metric"], rawSeries[0].data), ); - fillWithDefaultValue(settings, "pie.metric", defaultMetric, metricIsValid); fillWithDefaultValue(settings, "pie.sort_rows", getDefaultSortRows); @@ -59,6 +53,11 @@ export function computeStaticPieChartSettings( fillWithDefaultValue(settings, "pie.show_legend", getDefaultShowLegend()); fillWithDefaultValue(settings, "pie.show_total", getDefaultShowTotal()); + fillWithDefaultValue( + settings, + "pie.show_labels", + getDefaultShowLabels(settings), + ); fillWithDefaultValue( settings, "pie.percent_visibility", diff --git a/frontend/src/metabase/static-viz/components/PieChart/stories-data/index.ts b/frontend/src/metabase/static-viz/components/PieChart/stories-data/index.ts index 685977a0545fedcd6bf1ed9713e6d236bb14f5e2..6a9e9a93591a0cbaf1321def922c57f7a21c5441 100644 --- a/frontend/src/metabase/static-viz/components/PieChart/stories-data/index.ts +++ b/frontend/src/metabase/static-viz/components/PieChart/stories-data/index.ts @@ -12,6 +12,8 @@ import defaultSettings from "./default-settings.json"; import hideLegend from "./hide-legend.json"; import hideTotal from "./hide-total.json"; import invalidDimensionSetting44085 from "./invalid-dimension-setting-44085.json"; +import labelsOnChart from "./labels-on-chart.json"; +import labelsWithPercent from "./labels-with-percent.json"; import largeMinimumSlicePercentage from "./large-min-slice-percentage.json"; import longDimensionName from "./long-dimension-name.json"; import missingColors44087 from "./missing-colors-44087.json"; @@ -35,8 +37,12 @@ import showPercentagesOnChartDense from "./show-percentages-on-chart-dense.json" import showPercentagesOnChart from "./show-percentages-on-chart.json"; import singleDimension from "./single-dimension.json"; import smallMinimumSlicePercentage from "./small-min-slice-percentage.json"; +import threeRingsNoLabels from "./three-rings-no-labels.json"; +import threeRingsOtherSlices from "./three-rings-other-slices.json"; +import threeRings from "./three-rings.json"; import tinySlicesDisappear43766 from "./tiny-slices-disappear-43766.json"; import truncatedTotal from "./truncated-total.json"; +import twoRings from "./two-rings.json"; import unaggregatedDimension from "./unaggregated-dimension.json"; import zeroMinimumSlicePercentage from "./zero-min-slice-percentage.json"; @@ -82,4 +88,10 @@ export const data = { noSingleColumnLegend45149, numericSQLColumnCrashes28568, missingLabelLargeSlice38424, + twoRings, + threeRings, + threeRingsNoLabels, + threeRingsOtherSlices, + labelsWithPercent, + labelsOnChart, }; diff --git a/frontend/src/metabase/static-viz/components/PieChart/stories-data/labels-on-chart.json b/frontend/src/metabase/static-viz/components/PieChart/stories-data/labels-on-chart.json new file mode 100644 index 0000000000000000000000000000000000000000..e0e27c0cbfb778dcfb060111e3bdec64076314b1 --- /dev/null +++ b/frontend/src/metabase/static-viz/components/PieChart/stories-data/labels-on-chart.json @@ -0,0 +1,368 @@ +[ + { + "card": { + "original_card_id": 445, + "table_name": null, + "initial_sync_status": null, + "scores": [ + { + "weight": 2, + "score": 0, + "name": "pinned" + }, + { + "weight": 2, + "score": 0, + "name": "bookmarked" + }, + { + "weight": 1.5, + "score": 1, + "name": "recency" + }, + { + "weight": 1, + "score": 0.02, + "name": "dashboard" + }, + { + "weight": 0.5, + "score": 0.5, + "name": "model" + }, + { + "score": 1, + "name": "text-exact-match", + "weight": 4.444444444444444, + "match": "Pie - Labels on Chart - Poke Count by Type 1", + "match-context-thunk": "metabase.search.scoring$text_scores_with$iter__134420__134426$fn__134427$iter__134422__134428$fn__134429$fn__134430$fn__134433@736f3ed3", + "column": "name" + }, + { + "score": 1, + "name": "text-consecutivity", + "weight": 2.222222222222222, + "match": "Pie - Labels on Chart - Poke Count by Type 1", + "match-context-thunk": "metabase.search.scoring$text_scores_with$iter__134420__134426$fn__134427$iter__134422__134428$fn__134429$fn__134430$fn__134433@7750b466", + "column": "name" + }, + { + "score": 1, + "name": "text-total-occurrences", + "weight": 2.222222222222222, + "match": "Pie - Labels on Chart - Poke Count by Type 1", + "match-context-thunk": "metabase.search.scoring$text_scores_with$iter__134420__134426$fn__134427$iter__134422__134428$fn__134429$fn__134430$fn__134433@325234e8", + "column": "name" + }, + { + "score": 0.2727272727272727, + "name": "text-fullness", + "weight": 1.111111111111111, + "match": "Pie - Labels on Chart - Poke Count by Type 1", + "match-context-thunk": "metabase.search.scoring$text_scores_with$iter__134420__134426$fn__134427$iter__134422__134428$fn__134429$fn__134430$fn__134433@9b90ddb", + "column": "name" + } + ], + "context": null, + "dashboardcard_count": 1, + "table_description": null, + "last_edited_at": "2024-09-13T17:43:10.364621Z", + "model_name": null, + "last_editor_id": 1, + "effective_location": null, + "model_id": null, + "model_index_id": null, + "collection_authority_level": null, + "creator_common_name": "Emmad Usmani", + "table_schema": null, + "pk_ref": null, + "database_name": null, + "last_editor_common_name": "Emmad Usmani", + "bookmark": false, + "model": "card", + "location": null, + "moderated_status": null, + "fully_parameterized": true, + "can_delete": false, + "public_uuid": null, + "parameter_usage_count": 0, + "created_at": "2024-09-13T17:43:10.100482Z", + "parameters": [], + "metabase_version": "v0.1.33-SNAPSHOT (8755117)", + "collection": { + "id": 23, + "name": "Pie", + "authority_level": null, + "type": null + }, + "visualization_settings": { + "pie.show_labels": true + }, + "last-edit-info": { + "id": 1, + "last_name": "Usmani", + "first_name": "Emmad", + "email": "emmad@metabase.com", + "timestamp": "2024-09-13T17:43:10.364621Z" + }, + "collection_preview": true, + "entity_id": "TN-g0zMacqF7YzocQPJWr", + "archived_directly": false, + "display": "pie", + "parameter_mappings": [], + "id": 445, + "dataset_query": { + "database": 2, + "type": "query", + "query": { + "aggregation": [["count"]], + "breakout": [ + [ + "field", + "type_1", + { + "base-type": "type/Text" + } + ] + ], + "source-table": "card__104" + } + }, + "cache_ttl": null, + "embedding_params": null, + "made_public_by_id": null, + "updated_at": "2024-09-13T17:43:10.100482Z", + "moderation_reviews": [], + "can_restore": false, + "creator_id": 1, + "average_query_time": null, + "type": "question", + "last_used_at": "2024-09-13T17:43:10.100482Z", + "dashboard_count": 0, + "last_query_start": null, + "name": "Pie - Labels on Chart - Poke Count by Type 1", + "query_type": "query", + "collection_id": 23, + "enable_embedding": false, + "database_id": null, + "trashed_from_collection_id": null, + "can_write": true, + "initially_published_at": null, + "creator": { + "email": "emmad@metabase.com", + "first_name": "Emmad", + "last_login": "2024-09-13T17:05:26.388255Z", + "is_qbnewb": false, + "is_superuser": true, + "id": 1, + "last_name": "Usmani", + "date_joined": "2023-11-21T21:25:41.062104Z", + "common_name": "Emmad Usmani" + }, + "result_metadata": [ + { + "semantic_type": "type/Category", + "name": "type_1", + "field_ref": [ + "field", + "type_1", + { + "base-type": "type/Text" + } + ], + "effective_type": "type/Text", + "id": 1475, + "visibility_type": "normal", + "display_name": "Type 1", + "fingerprint": { + "global": { + "distinct-count": 18, + "nil%": 0 + }, + "type": { + "type/Text": { + "percent-json": 0, + "percent-url": 0, + "percent-email": 0, + "percent-state": 0, + "average-length": 5.281128404669261 + } + } + }, + "base_type": "type/Text" + }, + { + "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": 16, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 8, + "q1": 38, + "q3": 76, + "max": 134, + "sd": 32.210500459776796, + "avg": 57.111111111111114 + } + } + } + } + ], + "can_run_adhoc_query": true, + "table_id": null, + "source_card_id": 104, + "collection_position": null, + "view_count": 0, + "archived": false, + "description": null, + "cache_invalidated_at": null, + "displayIsLocked": true + }, + "data": { + "rows": [ + ["Bug", 81], + ["Dark", 44], + ["Dragon", 40], + ["Electric", 61], + ["Fairy", 22], + ["Fighting", 38], + ["Fire", 65], + ["Flying", 8], + ["Ghost", 41], + ["Grass", 91], + ["Ground", 41], + ["Ice", 36], + ["Normal", 115], + ["Poison", 39], + ["Psychic", 76], + ["Rock", 60], + ["Steel", 36], + ["Water", 134] + ], + "cols": [ + { + "database_type": "varchar", + "semantic_type": "type/Category", + "table_id": 156, + "name": "type_1", + "source": "breakout", + "field_ref": [ + "field", + "type_1", + { + "base-type": "type/Text" + } + ], + "effective_type": "type/Text", + "id": 1475, + "position": 9, + "visibility_type": "normal", + "display_name": "Type 1", + "fingerprint": { + "global": { + "distinct-count": 18, + "nil%": 0 + }, + "type": { + "type/Text": { + "percent-json": 0, + "percent-url": 0, + "percent-email": 0, + "percent-state": 0, + "average-length": 5.281128404669261 + } + } + }, + "base_type": "type/Text" + }, + { + "database_type": "int8", + "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 \"source\".\"type_1\" AS \"type_1\", COUNT(*) AS \"count\" FROM (SELECT \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"_mb_row_id\" AS \"_mb_row_id\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"pokedex_number\" AS \"pokedex_number\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"name\" AS \"name\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"german_name\" AS \"german_name\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"japanese_name\" AS \"japanese_name\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"generation\" AS \"generation\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"status\" AS \"status\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"species\" AS \"species\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"type_number\" AS \"type_number\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"type_1\" AS \"type_1\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"type_2\" AS \"type_2\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"height_m\" AS \"height_m\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"weight_kg\" AS \"weight_kg\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"abilities_number\" AS \"abilities_number\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"ability_1\" AS \"ability_1\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"ability_2\" AS \"ability_2\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"ability_hidden\" AS \"ability_hidden\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"total_points\" AS \"total_points\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"hp\" AS \"hp\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"attack\" AS \"attack\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"defense\" AS \"defense\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"sp_attack\" AS \"sp_attack\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"sp_defense\" AS \"sp_defense\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"speed\" AS \"speed\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"catch_rate\" AS \"catch_rate\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"base_friendship\" AS \"base_friendship\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"base_experience\" AS \"base_experience\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"growth_rate\" AS \"growth_rate\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"egg_type_number\" AS \"egg_type_number\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"egg_type_1\" AS \"egg_type_1\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"egg_type_2\" AS \"egg_type_2\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"percentage_male\" AS \"percentage_male\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"egg_cycles\" AS \"egg_cycles\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_normal\" AS \"against_normal\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_fire\" AS \"against_fire\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_water\" AS \"against_water\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_electric\" AS \"against_electric\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_grass\" AS \"against_grass\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_ice\" AS \"against_ice\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_fight\" AS \"against_fight\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_poison\" AS \"against_poison\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_ground\" AS \"against_ground\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_flying\" AS \"against_flying\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_psychic\" AS \"against_psychic\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_bug\" AS \"against_bug\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_rock\" AS \"against_rock\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_ghost\" AS \"against_ghost\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_dragon\" AS \"against_dragon\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_dark\" AS \"against_dark\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_steel\" AS \"against_steel\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_fairy\" AS \"against_fairy\" FROM \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\") AS \"source\" GROUP BY \"source\".\"type_1\" ORDER BY \"source\".\"type_1\" ASC", + "params": null + }, + "dataset": true, + "model": true, + "format-rows?": true, + "results_timezone": "America/Los_Angeles", + "results_metadata": { + "columns": [ + { + "semantic_type": "type/Category", + "name": "type_1", + "field_ref": [ + "field", + "type_1", + { + "base-type": "type/Text" + } + ], + "effective_type": "type/Text", + "id": 1475, + "visibility_type": "normal", + "display_name": "Type 1", + "fingerprint": { + "global": { + "distinct-count": 18, + "nil%": 0 + }, + "type": { + "type/Text": { + "percent-json": 0, + "percent-url": 0, + "percent-email": 0, + "percent-state": 0, + "average-length": 5.281128404669261 + } + } + }, + "base_type": "type/Text" + }, + { + "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": 16, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 8, + "q1": 38, + "q3": 76, + "max": 134, + "sd": 32.210500459776796, + "avg": 57.111111111111114 + } + } + } + } + ] + }, + "insights": null + } + } +] diff --git a/frontend/src/metabase/static-viz/components/PieChart/stories-data/labels-with-percent.json b/frontend/src/metabase/static-viz/components/PieChart/stories-data/labels-with-percent.json new file mode 100644 index 0000000000000000000000000000000000000000..afcfd7aac5f24b40e3a124a585492e2843841b92 --- /dev/null +++ b/frontend/src/metabase/static-viz/components/PieChart/stories-data/labels-with-percent.json @@ -0,0 +1,525 @@ +[ + { + "card": { + "original_card_id": 444, + "can_delete": false, + "public_uuid": null, + "parameter_usage_count": 0, + "created_at": "2024-09-13T17:32:13.380475Z", + "parameters": [], + "metabase_version": "v0.1.33-SNAPSHOT (8755117)", + "collection": { + "authority_level": null, + "description": null, + "archived": false, + "trashed_from_location": null, + "slug": "sunburst", + "archive_operation_id": null, + "name": "Sunburst", + "personal_owner_id": null, + "type": null, + "is_sample": false, + "id": 32, + "archived_directly": null, + "entity_id": "yivDsLk9XUmdCWr0lt9GJ", + "location": "/5/23/", + "namespace": null, + "is_personal": false, + "created_at": "2024-08-25T18:16:32.499168Z" + }, + "visualization_settings": { + "pie.dimension": ["CREATED_AT", "CATEGORY"], + "pie.middle_dimension": "CREATED_AT", + "pie.metric": "count", + "pie.rows": [ + { + "key": "2", + "name": "Mon", + "originalName": "Monday", + "color": "#227FD2", + "defaultColor": false, + "enabled": true, + "hidden": false, + "isOther": false + }, + { + "key": "3", + "name": "Tue", + "originalName": "Tuesday", + "color": "#689636", + "defaultColor": false, + "enabled": true, + "hidden": false, + "isOther": false + }, + { + "key": "4", + "name": "Wed", + "originalName": "Wednesday", + "color": "#8A5EB0", + "defaultColor": false, + "enabled": true, + "hidden": false, + "isOther": false + }, + { + "key": "5", + "name": "Thur", + "originalName": "Thursday", + "color": "#F7C41F", + "defaultColor": false, + "enabled": true, + "hidden": false, + "isOther": false + }, + { + "key": "6", + "name": "Fri", + "originalName": "Friday", + "color": "#69C8C8", + "defaultColor": false, + "enabled": true, + "hidden": false, + "isOther": false + }, + { + "isOther": false, + "hidden": false, + "enabled": false, + "defaultColor": true, + "color": "#7172AD", + "originalName": "Saturday", + "name": "Saturday", + "key": "7" + }, + { + "isOther": false, + "hidden": false, + "enabled": false, + "defaultColor": true, + "color": "#88BF4D", + "originalName": "Sunday", + "name": "Sunday", + "key": "1" + } + ], + "pie.sort_rows": false, + "pie.percent_visibility": "both", + "column_settings": { + "[\"name\",\"CREATED_AT\"]": { + "date_abbreviate": false + } + } + }, + "last-edit-info": { + "id": 1, + "email": "emmad@metabase.com", + "first_name": "Emmad", + "last_name": "Usmani", + "timestamp": "2024-09-13T17:32:13.665731Z" + }, + "collection_preview": true, + "entity_id": "Td3JZQD_ovbIlirGTI4QK", + "archived_directly": false, + "display": "pie", + "parameter_mappings": [], + "id": 444, + "dataset_query": { + "database": 1, + "type": "query", + "query": { + "source-table": 2, + "aggregation": [["count"]], + "breakout": [ + [ + "field", + 58, + { + "base-type": "type/Text", + "source-field": 40 + } + ], + [ + "field", + 41, + { + "base-type": "type/DateTime", + "temporal-unit": "day-of-week" + } + ] + ] + } + }, + "cache_ttl": null, + "embedding_params": null, + "made_public_by_id": null, + "updated_at": "2024-09-13T17:32:13.380475Z", + "moderation_reviews": [], + "can_restore": false, + "creator_id": 1, + "average_query_time": null, + "type": "question", + "last_used_at": "2024-09-13T17:32:13.380475Z", + "dashboard_count": 0, + "last_query_start": null, + "name": "Sunburst - Labels with Percent - orders weekdays, category", + "query_type": "query", + "collection_id": 32, + "enable_embedding": false, + "database_id": 1, + "trashed_from_collection_id": null, + "can_write": true, + "initially_published_at": null, + "creator": { + "email": "emmad@metabase.com", + "first_name": "Emmad", + "last_login": "2024-09-13T17:05:26.388255Z", + "is_qbnewb": false, + "is_superuser": true, + "id": 1, + "last_name": "Usmani", + "date_joined": "2023-11-21T21:25:41.062104Z", + "common_name": "Emmad Usmani" + }, + "result_metadata": [ + { + "description": "The type of product, valid values include: Doohicky, Gadget, Gizmo and Widget", + "semantic_type": "type/Category", + "coercion_strategy": null, + "name": "CATEGORY", + "settings": null, + "fk_target_field_id": null, + "field_ref": [ + "field", + 58, + { + "base-type": "type/Text", + "source-field": 40 + } + ], + "effective_type": "type/Text", + "id": 58, + "visibility_type": "normal", + "display_name": "Product → Category", + "fingerprint": { + "global": { + "distinct-count": 4, + "nil%": 0 + }, + "type": { + "type/Text": { + "percent-json": 0, + "percent-url": 0, + "percent-email": 0, + "percent-state": 0, + "average-length": 6.375 + } + } + }, + "base_type": "type/Text" + }, + { + "description": "The date and time an order was submitted.", + "semantic_type": "type/CreationTimestamp", + "coercion_strategy": null, + "unit": "day-of-week", + "name": "CREATED_AT", + "settings": null, + "fk_target_field_id": null, + "field_ref": [ + "field", + 41, + { + "base-type": "type/DateTime", + "temporal-unit": "day-of-week" + } + ], + "effective_type": "type/Integer", + "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/Integer" + }, + { + "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": 27, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 537, + "q1": 622, + "q3": 717.5, + "max": 763, + "sd": 67.4245806369908, + "avg": 670 + } + } + } + } + ], + "can_run_adhoc_query": true, + "table_id": 2, + "source_card_id": null, + "collection_position": null, + "view_count": 0, + "archived": false, + "description": null, + "cache_invalidated_at": null, + "displayIsLocked": true + }, + "data": { + "rows": [ + ["Doohickey", 1, 566], + ["Doohickey", 2, 537], + ["Doohickey", 3, 553], + ["Doohickey", 4, 563], + ["Doohickey", 5, 569], + ["Doohickey", 6, 582], + ["Doohickey", 7, 606], + ["Gadget", 1, 672], + ["Gadget", 2, 693], + ["Gadget", 3, 763], + ["Gadget", 4, 638], + ["Gadget", 5, 724], + ["Gadget", 6, 709], + ["Gadget", 7, 740], + ["Gizmo", 1, 717], + ["Gizmo", 2, 696], + ["Gizmo", 3, 680], + ["Gizmo", 4, 704], + ["Gizmo", 5, 685], + ["Gizmo", 6, 662], + ["Gizmo", 7, 640], + ["Widget", 1, 716], + ["Widget", 2, 738], + ["Widget", 3, 699], + ["Widget", 4, 718], + ["Widget", 5, 720], + ["Widget", 6, 709], + ["Widget", 7, 761] + ], + "cols": [ + { + "description": "The type of product, valid values include: Doohicky, Gadget, Gizmo and Widget", + "database_type": "CHARACTER VARYING", + "semantic_type": "type/Category", + "table_id": 1, + "coercion_strategy": null, + "name": "CATEGORY", + "settings": null, + "source": "breakout", + "fk_target_field_id": null, + "fk_field_id": 40, + "field_ref": [ + "field", + 58, + { + "base-type": "type/Text", + "source-field": 40 + } + ], + "effective_type": "type/Text", + "nfc_path": null, + "parent_id": null, + "id": 58, + "position": 3, + "visibility_type": "normal", + "display_name": "Product → Category", + "fingerprint": { + "global": { + "distinct-count": 4, + "nil%": 0 + }, + "type": { + "type/Text": { + "percent-json": 0, + "percent-url": 0, + "percent-email": 0, + "percent-state": 0, + "average-length": 6.375 + } + } + }, + "base_type": "type/Text", + "source_alias": "PRODUCTS__via__PRODUCT_ID" + }, + { + "description": "The date and time an order was submitted.", + "database_type": "INTEGER", + "semantic_type": "type/CreationTimestamp", + "table_id": 2, + "coercion_strategy": null, + "unit": "day-of-week", + "name": "CREATED_AT", + "settings": null, + "source": "breakout", + "fk_target_field_id": null, + "field_ref": [ + "field", + 41, + { + "base-type": "type/DateTime", + "temporal-unit": "day-of-week" + } + ], + "effective_type": "type/Integer", + "nfc_path": null, + "parent_id": null, + "id": 41, + "position": 7, + "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/Integer" + }, + { + "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 \"PRODUCTS__via__PRODUCT_ID\".\"CATEGORY\" AS \"PRODUCTS__via__PRODUCT_ID__CATEGORY\", COALESCE(NULLIF((extract(iso_day_of_week from \"PUBLIC\".\"ORDERS\".\"CREATED_AT\") + 1) % 7, 0), 7) AS \"CREATED_AT\", COUNT(*) AS \"count\" FROM \"PUBLIC\".\"ORDERS\" LEFT JOIN \"PUBLIC\".\"PRODUCTS\" AS \"PRODUCTS__via__PRODUCT_ID\" ON \"PUBLIC\".\"ORDERS\".\"PRODUCT_ID\" = \"PRODUCTS__via__PRODUCT_ID\".\"ID\" GROUP BY \"PRODUCTS__via__PRODUCT_ID\".\"CATEGORY\", COALESCE(NULLIF((extract(iso_day_of_week from \"PUBLIC\".\"ORDERS\".\"CREATED_AT\") + 1) % 7, 0), 7) ORDER BY \"PRODUCTS__via__PRODUCT_ID\".\"CATEGORY\" ASC, COALESCE(NULLIF((extract(iso_day_of_week from \"PUBLIC\".\"ORDERS\".\"CREATED_AT\") + 1) % 7, 0), 7) ASC", + "params": null + }, + "format-rows?": true, + "results_timezone": "America/Los_Angeles", + "results_metadata": { + "columns": [ + { + "description": "The type of product, valid values include: Doohicky, Gadget, Gizmo and Widget", + "semantic_type": "type/Category", + "coercion_strategy": null, + "name": "CATEGORY", + "settings": null, + "fk_target_field_id": null, + "field_ref": [ + "field", + 58, + { + "base-type": "type/Text", + "source-field": 40 + } + ], + "effective_type": "type/Text", + "id": 58, + "visibility_type": "normal", + "display_name": "Product → Category", + "fingerprint": { + "global": { + "distinct-count": 4, + "nil%": 0 + }, + "type": { + "type/Text": { + "percent-json": 0, + "percent-url": 0, + "percent-email": 0, + "percent-state": 0, + "average-length": 6.375 + } + } + }, + "base_type": "type/Text" + }, + { + "description": "The date and time an order was submitted.", + "semantic_type": "type/CreationTimestamp", + "coercion_strategy": null, + "unit": "day-of-week", + "name": "CREATED_AT", + "settings": null, + "fk_target_field_id": null, + "field_ref": [ + "field", + 41, + { + "base-type": "type/DateTime", + "temporal-unit": "day-of-week" + } + ], + "effective_type": "type/Integer", + "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/Integer" + }, + { + "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": 27, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 537, + "q1": 622, + "q3": 717.5, + "max": 763, + "sd": 67.4245806369908, + "avg": 670 + } + } + } + } + ] + }, + "insights": null + } + } +] diff --git a/frontend/src/metabase/static-viz/components/PieChart/stories-data/three-rings-no-labels.json b/frontend/src/metabase/static-viz/components/PieChart/stories-data/three-rings-no-labels.json new file mode 100644 index 0000000000000000000000000000000000000000..d9fdf84a23bc01b367530eb286db8a1e08c6f707 --- /dev/null +++ b/frontend/src/metabase/static-viz/components/PieChart/stories-data/three-rings-no-labels.json @@ -0,0 +1,603 @@ +[ + { + "card": { + "original_card_id": 437, + "can_delete": false, + "public_uuid": null, + "parameter_usage_count": 0, + "created_at": "2024-09-11T15:27:33.439408Z", + "parameters": [], + "metabase_version": "v0.1.33-SNAPSHOT (8755117)", + "collection": { + "authority_level": null, + "description": null, + "archived": false, + "trashed_from_location": null, + "slug": "sunburst", + "archive_operation_id": null, + "name": "Sunburst", + "personal_owner_id": null, + "type": null, + "is_sample": false, + "id": 32, + "archived_directly": null, + "entity_id": "yivDsLk9XUmdCWr0lt9GJ", + "location": "/5/23/", + "namespace": null, + "is_personal": false, + "created_at": "2024-08-25T18:16:32.499168Z" + }, + "visualization_settings": { + "table.pivot_column": "generation", + "table.cell_column": "count", + "pie.dimension": ["generation", "type_1", "type_2"], + "pie.metric": "count", + "pie.slice_threshold": 0, + "pie.show_labels": false + }, + "last-edit-info": { + "id": 1, + "email": "emmad@metabase.com", + "first_name": "Emmad", + "last_name": "Usmani", + "timestamp": "2024-09-12T20:57:42.021468Z" + }, + "collection_preview": true, + "entity_id": "ue-evUeQ07b-xZEr48VAk", + "archived_directly": false, + "display": "pie", + "parameter_mappings": [], + "id": 437, + "dataset_query": { + "database": 2, + "type": "query", + "query": { + "aggregation": [["count"]], + "breakout": [ + [ + "field", + "generation", + { + "base-type": "type/BigInteger" + } + ], + [ + "field", + "type_1", + { + "base-type": "type/Text" + } + ], + [ + "field", + "type_2", + { + "base-type": "type/Text" + } + ] + ], + "order-by": [["desc", ["aggregation", 0]]], + "source-table": "card__104", + "filter": [ + "and", + [ + "=", + [ + "field", + "type_1", + { + "base-type": "type/Text" + } + ], + "Water", + "Grass", + "Fire" + ], + [ + "not-empty", + [ + "field", + "type_2", + { + "base-type": "type/Text" + } + ] + ] + ] + } + }, + "cache_ttl": null, + "embedding_params": null, + "made_public_by_id": null, + "updated_at": "2024-09-12T20:57:41.785432Z", + "moderation_reviews": [], + "can_restore": false, + "creator_id": 1, + "average_query_time": 487.7894736842105, + "type": "question", + "last_used_at": "2024-09-13T17:05:33.584358Z", + "dashboard_count": 1, + "last_query_start": "2024-09-13T17:05:33.238198Z", + "name": "Sunburst - Poke Count by Gen, Type 1, Type 2, 0 MSP", + "query_type": "query", + "collection_id": 32, + "enable_embedding": false, + "database_id": 2, + "trashed_from_collection_id": null, + "can_write": true, + "initially_published_at": null, + "creator": { + "email": "emmad@metabase.com", + "first_name": "Emmad", + "last_login": "2024-09-13T17:05:26.388255Z", + "is_qbnewb": false, + "is_superuser": true, + "id": 1, + "last_name": "Usmani", + "date_joined": "2023-11-21T21:25:41.062104Z", + "common_name": "Emmad Usmani" + }, + "result_metadata": [ + { + "semantic_type": "type/Category", + "name": "generation", + "field_ref": [ + "field", + "generation", + { + "base-type": "type/BigInteger" + } + ], + "effective_type": "type/BigInteger", + "id": 1503, + "visibility_type": "normal", + "display_name": "Generation", + "fingerprint": { + "global": { + "distinct-count": 8, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 1, + "q1": 2.1045160333142734, + "q3": 5.716989271844637, + "max": 8, + "sd": 2.234937326790694, + "avg": 4.034046692607004 + } + } + }, + "base_type": "type/BigInteger" + }, + { + "semantic_type": "type/Category", + "name": "type_1", + "field_ref": [ + "field", + "type_1", + { + "base-type": "type/Text" + } + ], + "effective_type": "type/Text", + "id": 1475, + "visibility_type": "normal", + "display_name": "Type 1", + "fingerprint": { + "global": { + "distinct-count": 18, + "nil%": 0 + }, + "type": { + "type/Text": { + "percent-json": 0, + "percent-url": 0, + "percent-email": 0, + "percent-state": 0, + "average-length": 5.281128404669261 + } + } + }, + "base_type": "type/Text" + }, + { + "semantic_type": "type/Category", + "name": "type_2", + "field_ref": [ + "field", + "type_2", + { + "base-type": "type/Text" + } + ], + "effective_type": "type/Text", + "id": 1499, + "visibility_type": "normal", + "display_name": "Type 2", + "fingerprint": { + "global": { + "distinct-count": 19, + "nil%": 0.4727626459143969 + }, + "type": { + "type/Text": { + "percent-json": 0, + "percent-url": 0, + "percent-email": 0, + "percent-state": 0, + "average-length": 2.9474708171206228 + } + } + }, + "base_type": "type/Text" + }, + { + "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": 6, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 1, + "q1": 1, + "q3": 2.2964690541943424, + "max": 10, + "sd": 1.2898977338673652, + "avg": 1.8076923076923077 + } + } + } + } + ], + "can_run_adhoc_query": true, + "table_id": 156, + "source_card_id": 104, + "collection_position": null, + "view_count": 35, + "archived": false, + "description": null, + "cache_invalidated_at": null, + "displayIsLocked": true + }, + "data": { + "rows": [ + [1, "Grass", "Poison", 10], + [3, "Water", "Ground", 5], + [1, "Water", "Psychic", 4], + [3, "Water", "Dark", 4], + [1, "Fire", "Flying", 3], + [1, "Water", "Ice", 3], + [2, "Grass", "Flying", 3], + [3, "Fire", "Fighting", 3], + [3, "Fire", "Ground", 3], + [3, "Grass", "Dark", 3], + [3, "Water", "Grass", 3], + [4, "Grass", "Ice", 3], + [7, "Grass", "Fairy", 3], + [8, "Grass", "Dragon", 3], + [1, "Grass", "Psychic", 2], + [1, "Water", "Poison", 2], + [2, "Water", "Electric", 2], + [2, "Water", "Fairy", 2], + [2, "Water", "Ground", 2], + [3, "Water", "Flying", 2], + [4, "Fire", "Fighting", 2], + [4, "Grass", "Poison", 2], + [5, "Fire", "Fighting", 2], + [5, "Grass", "Fairy", 2], + [5, "Grass", "Poison", 2], + [5, "Grass", "Steel", 2], + [5, "Water", "Fighting", 2], + [5, "Water", "Flying", 2], + [5, "Water", "Ghost", 2], + [5, "Water", "Ground", 2], + [5, "Water", "Rock", 2], + [6, "Fire", "Flying", 2], + [6, "Fire", "Normal", 2], + [6, "Water", "Dark", 2], + [7, "Grass", "Flying", 2], + [7, "Water", "Bug", 2], + [7, "Water", "Fairy", 2], + [8, "Fire", "Bug", 2], + [1, "Fire", "Dragon", 1], + [1, "Fire", "Ghost", 1], + [1, "Grass", "Dragon", 1], + [1, "Water", "Dark", 1], + [1, "Water", "Fighting", 1], + [1, "Water", "Flying", 1], + [2, "Fire", "Flying", 1], + [2, "Fire", "Rock", 1], + [2, "Water", "Dragon", 1], + [2, "Water", "Flying", 1], + [2, "Water", "Poison", 1], + [2, "Water", "Psychic", 1], + [2, "Water", "Rock", 1], + [3, "Grass", "Dragon", 1], + [3, "Grass", "Fighting", 1], + [3, "Grass", "Flying", 1], + [3, "Grass", "Poison", 1], + [3, "Water", "Rock", 1], + [4, "Fire", "Steel", 1], + [4, "Grass", "Flying", 1], + [4, "Grass", "Ground", 1], + [4, "Water", "Dragon", 1], + [4, "Water", "Flying", 1], + [4, "Water", "Ground", 1], + [4, "Water", "Steel", 1], + [5, "Fire", "Psychic", 1], + [5, "Grass", "Fighting", 1], + [6, "Fire", "Psychic", 1], + [6, "Fire", "Water", 1], + [6, "Grass", "Fighting", 1], + [7, "Fire", "Dark", 1], + [7, "Fire", "Dragon", 1], + [7, "Fire", "Flying", 1], + [7, "Fire", "Ghost", 1], + [7, "Grass", "Ghost", 1], + [7, "Grass", "Steel", 1], + [7, "Water", "Psychic", 1], + [8, "Water", "Dragon", 1], + [8, "Water", "Ice", 1], + [8, "Water", "Rock", 1] + ], + "cols": [ + { + "database_type": "int8", + "semantic_type": "type/Category", + "table_id": 156, + "name": "generation", + "source": "breakout", + "field_ref": [ + "field", + "generation", + { + "base-type": "type/BigInteger" + } + ], + "effective_type": "type/BigInteger", + "id": 1503, + "position": 5, + "visibility_type": "normal", + "display_name": "Generation", + "fingerprint": { + "global": { + "distinct-count": 8, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 1, + "q1": 2.1045160333142734, + "q3": 5.716989271844637, + "max": 8, + "sd": 2.234937326790694, + "avg": 4.034046692607004 + } + } + }, + "base_type": "type/BigInteger" + }, + { + "database_type": "varchar", + "semantic_type": "type/Category", + "table_id": 156, + "name": "type_1", + "source": "breakout", + "field_ref": [ + "field", + "type_1", + { + "base-type": "type/Text" + } + ], + "effective_type": "type/Text", + "id": 1475, + "position": 9, + "visibility_type": "normal", + "display_name": "Type 1", + "fingerprint": { + "global": { + "distinct-count": 18, + "nil%": 0 + }, + "type": { + "type/Text": { + "percent-json": 0, + "percent-url": 0, + "percent-email": 0, + "percent-state": 0, + "average-length": 5.281128404669261 + } + } + }, + "base_type": "type/Text" + }, + { + "database_type": "varchar", + "semantic_type": "type/Category", + "table_id": 156, + "name": "type_2", + "source": "breakout", + "field_ref": [ + "field", + "type_2", + { + "base-type": "type/Text" + } + ], + "effective_type": "type/Text", + "id": 1499, + "position": 10, + "visibility_type": "normal", + "display_name": "Type 2", + "fingerprint": { + "global": { + "distinct-count": 19, + "nil%": 0.4727626459143969 + }, + "type": { + "type/Text": { + "percent-json": 0, + "percent-url": 0, + "percent-email": 0, + "percent-state": 0, + "average-length": 2.9474708171206228 + } + } + }, + "base_type": "type/Text" + }, + { + "database_type": "int8", + "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 \"source\".\"generation\" AS \"generation\", \"source\".\"type_1\" AS \"type_1\", \"source\".\"type_2\" AS \"type_2\", COUNT(*) AS \"count\" FROM (SELECT \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"_mb_row_id\" AS \"_mb_row_id\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"pokedex_number\" AS \"pokedex_number\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"name\" AS \"name\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"german_name\" AS \"german_name\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"japanese_name\" AS \"japanese_name\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"generation\" AS \"generation\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"status\" AS \"status\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"species\" AS \"species\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"type_number\" AS \"type_number\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"type_1\" AS \"type_1\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"type_2\" AS \"type_2\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"height_m\" AS \"height_m\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"weight_kg\" AS \"weight_kg\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"abilities_number\" AS \"abilities_number\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"ability_1\" AS \"ability_1\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"ability_2\" AS \"ability_2\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"ability_hidden\" AS \"ability_hidden\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"total_points\" AS \"total_points\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"hp\" AS \"hp\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"attack\" AS \"attack\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"defense\" AS \"defense\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"sp_attack\" AS \"sp_attack\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"sp_defense\" AS \"sp_defense\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"speed\" AS \"speed\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"catch_rate\" AS \"catch_rate\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"base_friendship\" AS \"base_friendship\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"base_experience\" AS \"base_experience\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"growth_rate\" AS \"growth_rate\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"egg_type_number\" AS \"egg_type_number\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"egg_type_1\" AS \"egg_type_1\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"egg_type_2\" AS \"egg_type_2\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"percentage_male\" AS \"percentage_male\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"egg_cycles\" AS \"egg_cycles\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_normal\" AS \"against_normal\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_fire\" AS \"against_fire\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_water\" AS \"against_water\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_electric\" AS \"against_electric\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_grass\" AS \"against_grass\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_ice\" AS \"against_ice\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_fight\" AS \"against_fight\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_poison\" AS \"against_poison\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_ground\" AS \"against_ground\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_flying\" AS \"against_flying\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_psychic\" AS \"against_psychic\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_bug\" AS \"against_bug\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_rock\" AS \"against_rock\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_ghost\" AS \"against_ghost\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_dragon\" AS \"against_dragon\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_dark\" AS \"against_dark\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_steel\" AS \"against_steel\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_fairy\" AS \"against_fairy\" FROM \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\") AS \"source\" WHERE ((\"source\".\"type_1\" = 'Water') OR (\"source\".\"type_1\" = 'Grass') OR (\"source\".\"type_1\" = 'Fire')) AND (\"source\".\"type_2\" IS NOT NULL) AND ((\"source\".\"type_2\" <> '') OR (\"source\".\"type_2\" IS NULL)) GROUP BY \"source\".\"generation\", \"source\".\"type_1\", \"source\".\"type_2\" ORDER BY \"count\" DESC, \"source\".\"generation\" ASC, \"source\".\"type_1\" ASC, \"source\".\"type_2\" ASC", + "params": null + }, + "dataset": true, + "model": true, + "format-rows?": true, + "results_timezone": "America/Los_Angeles", + "results_metadata": { + "columns": [ + { + "semantic_type": "type/Category", + "name": "generation", + "field_ref": [ + "field", + "generation", + { + "base-type": "type/BigInteger" + } + ], + "effective_type": "type/BigInteger", + "id": 1503, + "visibility_type": "normal", + "display_name": "Generation", + "fingerprint": { + "global": { + "distinct-count": 8, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 1, + "q1": 2.1045160333142734, + "q3": 5.716989271844637, + "max": 8, + "sd": 2.234937326790694, + "avg": 4.034046692607004 + } + } + }, + "base_type": "type/BigInteger" + }, + { + "semantic_type": "type/Category", + "name": "type_1", + "field_ref": [ + "field", + "type_1", + { + "base-type": "type/Text" + } + ], + "effective_type": "type/Text", + "id": 1475, + "visibility_type": "normal", + "display_name": "Type 1", + "fingerprint": { + "global": { + "distinct-count": 18, + "nil%": 0 + }, + "type": { + "type/Text": { + "percent-json": 0, + "percent-url": 0, + "percent-email": 0, + "percent-state": 0, + "average-length": 5.281128404669261 + } + } + }, + "base_type": "type/Text" + }, + { + "semantic_type": "type/Category", + "name": "type_2", + "field_ref": [ + "field", + "type_2", + { + "base-type": "type/Text" + } + ], + "effective_type": "type/Text", + "id": 1499, + "visibility_type": "normal", + "display_name": "Type 2", + "fingerprint": { + "global": { + "distinct-count": 19, + "nil%": 0.4727626459143969 + }, + "type": { + "type/Text": { + "percent-json": 0, + "percent-url": 0, + "percent-email": 0, + "percent-state": 0, + "average-length": 2.9474708171206228 + } + } + }, + "base_type": "type/Text" + }, + { + "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": 6, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 1, + "q1": 1, + "q3": 2.2964690541943424, + "max": 10, + "sd": 1.2898977338673652, + "avg": 1.8076923076923077 + } + } + } + } + ] + }, + "insights": null + } + } +] diff --git a/frontend/src/metabase/static-viz/components/PieChart/stories-data/three-rings-other-slices.json b/frontend/src/metabase/static-viz/components/PieChart/stories-data/three-rings-other-slices.json new file mode 100644 index 0000000000000000000000000000000000000000..ed84018a607977742581dec22493d1094bc21e25 --- /dev/null +++ b/frontend/src/metabase/static-viz/components/PieChart/stories-data/three-rings-other-slices.json @@ -0,0 +1,604 @@ +[ + { + "card": { + "original_card_id": 435, + "can_delete": false, + "public_uuid": null, + "parameter_usage_count": 0, + "created_at": "2024-09-05T21:37:00.110724Z", + "parameters": [], + "metabase_version": "v0.1.33-SNAPSHOT (8755117)", + "collection": { + "authority_level": null, + "description": null, + "archived": false, + "trashed_from_location": null, + "slug": "sunburst", + "archive_operation_id": null, + "name": "Sunburst", + "personal_owner_id": null, + "type": null, + "is_sample": false, + "id": 32, + "archived_directly": null, + "entity_id": "yivDsLk9XUmdCWr0lt9GJ", + "location": "/5/23/", + "namespace": null, + "is_personal": false, + "created_at": "2024-08-25T18:16:32.499168Z" + }, + "visualization_settings": { + "table.pivot_column": "generation", + "table.cell_column": "count", + "pie.dimension": ["generation", "type_1", "type_2"], + "pie.middle_dimension": "type_1", + "pie.outer_dimension": "type_2", + "pie.metric": "count", + "pie.slice_threshold": 11 + }, + "last-edit-info": { + "id": 1, + "email": "emmad@metabase.com", + "first_name": "Emmad", + "last_name": "Usmani", + "timestamp": "2024-09-12T20:55:39.917649Z" + }, + "collection_preview": true, + "entity_id": "qrGpX7x2lkmsuiuF8YJw5", + "archived_directly": false, + "display": "pie", + "parameter_mappings": [], + "id": 435, + "dataset_query": { + "database": 2, + "type": "query", + "query": { + "aggregation": [["count"]], + "breakout": [ + [ + "field", + "generation", + { + "base-type": "type/BigInteger" + } + ], + [ + "field", + "type_1", + { + "base-type": "type/Text" + } + ], + [ + "field", + "type_2", + { + "base-type": "type/Text" + } + ] + ], + "order-by": [["desc", ["aggregation", 0]]], + "source-table": "card__104", + "filter": [ + "and", + [ + "=", + [ + "field", + "type_1", + { + "base-type": "type/Text" + } + ], + "Water", + "Grass", + "Fire" + ], + [ + "not-empty", + [ + "field", + "type_2", + { + "base-type": "type/Text" + } + ] + ] + ] + } + }, + "cache_ttl": null, + "embedding_params": null, + "made_public_by_id": null, + "updated_at": "2024-09-12T20:55:39.607203Z", + "moderation_reviews": [], + "can_restore": false, + "creator_id": 1, + "average_query_time": 483.0125, + "type": "question", + "last_used_at": "2024-09-13T17:32:18.893089Z", + "dashboard_count": 1, + "last_query_start": "2024-09-13T17:32:18.623548Z", + "name": "Sunburst - Poke Count by Gen, Type 1, Type 2, high MSP", + "query_type": "query", + "collection_id": 32, + "enable_embedding": false, + "database_id": 2, + "trashed_from_collection_id": null, + "can_write": true, + "initially_published_at": null, + "creator": { + "email": "emmad@metabase.com", + "first_name": "Emmad", + "last_login": "2024-09-13T17:05:26.388255Z", + "is_qbnewb": false, + "is_superuser": true, + "id": 1, + "last_name": "Usmani", + "date_joined": "2023-11-21T21:25:41.062104Z", + "common_name": "Emmad Usmani" + }, + "result_metadata": [ + { + "semantic_type": "type/Category", + "name": "generation", + "field_ref": [ + "field", + "generation", + { + "base-type": "type/BigInteger" + } + ], + "effective_type": "type/BigInteger", + "id": 1503, + "visibility_type": "normal", + "display_name": "Generation", + "fingerprint": { + "global": { + "distinct-count": 8, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 1, + "q1": 2.1045160333142734, + "q3": 5.716989271844637, + "max": 8, + "sd": 2.234937326790694, + "avg": 4.034046692607004 + } + } + }, + "base_type": "type/BigInteger" + }, + { + "semantic_type": "type/Category", + "name": "type_1", + "field_ref": [ + "field", + "type_1", + { + "base-type": "type/Text" + } + ], + "effective_type": "type/Text", + "id": 1475, + "visibility_type": "normal", + "display_name": "Type 1", + "fingerprint": { + "global": { + "distinct-count": 18, + "nil%": 0 + }, + "type": { + "type/Text": { + "percent-json": 0, + "percent-url": 0, + "percent-email": 0, + "percent-state": 0, + "average-length": 5.281128404669261 + } + } + }, + "base_type": "type/Text" + }, + { + "semantic_type": "type/Category", + "name": "type_2", + "field_ref": [ + "field", + "type_2", + { + "base-type": "type/Text" + } + ], + "effective_type": "type/Text", + "id": 1499, + "visibility_type": "normal", + "display_name": "Type 2", + "fingerprint": { + "global": { + "distinct-count": 19, + "nil%": 0.4727626459143969 + }, + "type": { + "type/Text": { + "percent-json": 0, + "percent-url": 0, + "percent-email": 0, + "percent-state": 0, + "average-length": 2.9474708171206228 + } + } + }, + "base_type": "type/Text" + }, + { + "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": 6, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 1, + "q1": 1, + "q3": 2.2964690541943424, + "max": 10, + "sd": 1.2898977338673652, + "avg": 1.8076923076923077 + } + } + } + } + ], + "can_run_adhoc_query": true, + "table_id": 156, + "source_card_id": 104, + "collection_position": null, + "view_count": 59, + "archived": false, + "description": null, + "cache_invalidated_at": null, + "displayIsLocked": true + }, + "data": { + "rows": [ + [1, "Grass", "Poison", 10], + [3, "Water", "Ground", 5], + [1, "Water", "Psychic", 4], + [3, "Water", "Dark", 4], + [1, "Fire", "Flying", 3], + [1, "Water", "Ice", 3], + [2, "Grass", "Flying", 3], + [3, "Fire", "Fighting", 3], + [3, "Fire", "Ground", 3], + [3, "Grass", "Dark", 3], + [3, "Water", "Grass", 3], + [4, "Grass", "Ice", 3], + [7, "Grass", "Fairy", 3], + [8, "Grass", "Dragon", 3], + [1, "Grass", "Psychic", 2], + [1, "Water", "Poison", 2], + [2, "Water", "Electric", 2], + [2, "Water", "Fairy", 2], + [2, "Water", "Ground", 2], + [3, "Water", "Flying", 2], + [4, "Fire", "Fighting", 2], + [4, "Grass", "Poison", 2], + [5, "Fire", "Fighting", 2], + [5, "Grass", "Fairy", 2], + [5, "Grass", "Poison", 2], + [5, "Grass", "Steel", 2], + [5, "Water", "Fighting", 2], + [5, "Water", "Flying", 2], + [5, "Water", "Ghost", 2], + [5, "Water", "Ground", 2], + [5, "Water", "Rock", 2], + [6, "Fire", "Flying", 2], + [6, "Fire", "Normal", 2], + [6, "Water", "Dark", 2], + [7, "Grass", "Flying", 2], + [7, "Water", "Bug", 2], + [7, "Water", "Fairy", 2], + [8, "Fire", "Bug", 2], + [1, "Fire", "Dragon", 1], + [1, "Fire", "Ghost", 1], + [1, "Grass", "Dragon", 1], + [1, "Water", "Dark", 1], + [1, "Water", "Fighting", 1], + [1, "Water", "Flying", 1], + [2, "Fire", "Flying", 1], + [2, "Fire", "Rock", 1], + [2, "Water", "Dragon", 1], + [2, "Water", "Flying", 1], + [2, "Water", "Poison", 1], + [2, "Water", "Psychic", 1], + [2, "Water", "Rock", 1], + [3, "Grass", "Dragon", 1], + [3, "Grass", "Fighting", 1], + [3, "Grass", "Flying", 1], + [3, "Grass", "Poison", 1], + [3, "Water", "Rock", 1], + [4, "Fire", "Steel", 1], + [4, "Grass", "Flying", 1], + [4, "Grass", "Ground", 1], + [4, "Water", "Dragon", 1], + [4, "Water", "Flying", 1], + [4, "Water", "Ground", 1], + [4, "Water", "Steel", 1], + [5, "Fire", "Psychic", 1], + [5, "Grass", "Fighting", 1], + [6, "Fire", "Psychic", 1], + [6, "Fire", "Water", 1], + [6, "Grass", "Fighting", 1], + [7, "Fire", "Dark", 1], + [7, "Fire", "Dragon", 1], + [7, "Fire", "Flying", 1], + [7, "Fire", "Ghost", 1], + [7, "Grass", "Ghost", 1], + [7, "Grass", "Steel", 1], + [7, "Water", "Psychic", 1], + [8, "Water", "Dragon", 1], + [8, "Water", "Ice", 1], + [8, "Water", "Rock", 1] + ], + "cols": [ + { + "database_type": "int8", + "semantic_type": "type/Category", + "table_id": 156, + "name": "generation", + "source": "breakout", + "field_ref": [ + "field", + "generation", + { + "base-type": "type/BigInteger" + } + ], + "effective_type": "type/BigInteger", + "id": 1503, + "position": 5, + "visibility_type": "normal", + "display_name": "Generation", + "fingerprint": { + "global": { + "distinct-count": 8, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 1, + "q1": 2.1045160333142734, + "q3": 5.716989271844637, + "max": 8, + "sd": 2.234937326790694, + "avg": 4.034046692607004 + } + } + }, + "base_type": "type/BigInteger" + }, + { + "database_type": "varchar", + "semantic_type": "type/Category", + "table_id": 156, + "name": "type_1", + "source": "breakout", + "field_ref": [ + "field", + "type_1", + { + "base-type": "type/Text" + } + ], + "effective_type": "type/Text", + "id": 1475, + "position": 9, + "visibility_type": "normal", + "display_name": "Type 1", + "fingerprint": { + "global": { + "distinct-count": 18, + "nil%": 0 + }, + "type": { + "type/Text": { + "percent-json": 0, + "percent-url": 0, + "percent-email": 0, + "percent-state": 0, + "average-length": 5.281128404669261 + } + } + }, + "base_type": "type/Text" + }, + { + "database_type": "varchar", + "semantic_type": "type/Category", + "table_id": 156, + "name": "type_2", + "source": "breakout", + "field_ref": [ + "field", + "type_2", + { + "base-type": "type/Text" + } + ], + "effective_type": "type/Text", + "id": 1499, + "position": 10, + "visibility_type": "normal", + "display_name": "Type 2", + "fingerprint": { + "global": { + "distinct-count": 19, + "nil%": 0.4727626459143969 + }, + "type": { + "type/Text": { + "percent-json": 0, + "percent-url": 0, + "percent-email": 0, + "percent-state": 0, + "average-length": 2.9474708171206228 + } + } + }, + "base_type": "type/Text" + }, + { + "database_type": "int8", + "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 \"source\".\"generation\" AS \"generation\", \"source\".\"type_1\" AS \"type_1\", \"source\".\"type_2\" AS \"type_2\", COUNT(*) AS \"count\" FROM (SELECT \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"_mb_row_id\" AS \"_mb_row_id\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"pokedex_number\" AS \"pokedex_number\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"name\" AS \"name\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"german_name\" AS \"german_name\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"japanese_name\" AS \"japanese_name\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"generation\" AS \"generation\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"status\" AS \"status\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"species\" AS \"species\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"type_number\" AS \"type_number\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"type_1\" AS \"type_1\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"type_2\" AS \"type_2\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"height_m\" AS \"height_m\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"weight_kg\" AS \"weight_kg\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"abilities_number\" AS \"abilities_number\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"ability_1\" AS \"ability_1\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"ability_2\" AS \"ability_2\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"ability_hidden\" AS \"ability_hidden\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"total_points\" AS \"total_points\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"hp\" AS \"hp\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"attack\" AS \"attack\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"defense\" AS \"defense\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"sp_attack\" AS \"sp_attack\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"sp_defense\" AS \"sp_defense\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"speed\" AS \"speed\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"catch_rate\" AS \"catch_rate\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"base_friendship\" AS \"base_friendship\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"base_experience\" AS \"base_experience\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"growth_rate\" AS \"growth_rate\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"egg_type_number\" AS \"egg_type_number\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"egg_type_1\" AS \"egg_type_1\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"egg_type_2\" AS \"egg_type_2\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"percentage_male\" AS \"percentage_male\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"egg_cycles\" AS \"egg_cycles\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_normal\" AS \"against_normal\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_fire\" AS \"against_fire\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_water\" AS \"against_water\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_electric\" AS \"against_electric\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_grass\" AS \"against_grass\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_ice\" AS \"against_ice\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_fight\" AS \"against_fight\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_poison\" AS \"against_poison\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_ground\" AS \"against_ground\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_flying\" AS \"against_flying\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_psychic\" AS \"against_psychic\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_bug\" AS \"against_bug\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_rock\" AS \"against_rock\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_ghost\" AS \"against_ghost\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_dragon\" AS \"against_dragon\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_dark\" AS \"against_dark\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_steel\" AS \"against_steel\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_fairy\" AS \"against_fairy\" FROM \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\") AS \"source\" WHERE ((\"source\".\"type_1\" = 'Water') OR (\"source\".\"type_1\" = 'Grass') OR (\"source\".\"type_1\" = 'Fire')) AND (\"source\".\"type_2\" IS NOT NULL) AND ((\"source\".\"type_2\" <> '') OR (\"source\".\"type_2\" IS NULL)) GROUP BY \"source\".\"generation\", \"source\".\"type_1\", \"source\".\"type_2\" ORDER BY \"count\" DESC, \"source\".\"generation\" ASC, \"source\".\"type_1\" ASC, \"source\".\"type_2\" ASC", + "params": null + }, + "dataset": true, + "model": true, + "format-rows?": true, + "results_timezone": "America/Los_Angeles", + "results_metadata": { + "columns": [ + { + "semantic_type": "type/Category", + "name": "generation", + "field_ref": [ + "field", + "generation", + { + "base-type": "type/BigInteger" + } + ], + "effective_type": "type/BigInteger", + "id": 1503, + "visibility_type": "normal", + "display_name": "Generation", + "fingerprint": { + "global": { + "distinct-count": 8, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 1, + "q1": 2.1045160333142734, + "q3": 5.716989271844637, + "max": 8, + "sd": 2.234937326790694, + "avg": 4.034046692607004 + } + } + }, + "base_type": "type/BigInteger" + }, + { + "semantic_type": "type/Category", + "name": "type_1", + "field_ref": [ + "field", + "type_1", + { + "base-type": "type/Text" + } + ], + "effective_type": "type/Text", + "id": 1475, + "visibility_type": "normal", + "display_name": "Type 1", + "fingerprint": { + "global": { + "distinct-count": 18, + "nil%": 0 + }, + "type": { + "type/Text": { + "percent-json": 0, + "percent-url": 0, + "percent-email": 0, + "percent-state": 0, + "average-length": 5.281128404669261 + } + } + }, + "base_type": "type/Text" + }, + { + "semantic_type": "type/Category", + "name": "type_2", + "field_ref": [ + "field", + "type_2", + { + "base-type": "type/Text" + } + ], + "effective_type": "type/Text", + "id": 1499, + "visibility_type": "normal", + "display_name": "Type 2", + "fingerprint": { + "global": { + "distinct-count": 19, + "nil%": 0.4727626459143969 + }, + "type": { + "type/Text": { + "percent-json": 0, + "percent-url": 0, + "percent-email": 0, + "percent-state": 0, + "average-length": 2.9474708171206228 + } + } + }, + "base_type": "type/Text" + }, + { + "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": 6, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 1, + "q1": 1, + "q3": 2.2964690541943424, + "max": 10, + "sd": 1.2898977338673652, + "avg": 1.8076923076923077 + } + } + } + } + ] + }, + "insights": null + } + } +] diff --git a/frontend/src/metabase/static-viz/components/PieChart/stories-data/three-rings.json b/frontend/src/metabase/static-viz/components/PieChart/stories-data/three-rings.json new file mode 100644 index 0000000000000000000000000000000000000000..454fb279b3962939a2958526b8761af2990a2791 --- /dev/null +++ b/frontend/src/metabase/static-viz/components/PieChart/stories-data/three-rings.json @@ -0,0 +1,602 @@ +[ + { + "card": { + "original_card_id": 437, + "can_delete": false, + "public_uuid": null, + "parameter_usage_count": 0, + "created_at": "2024-09-11T15:27:33.439408Z", + "parameters": [], + "metabase_version": "v0.1.33-SNAPSHOT (8755117)", + "collection": { + "authority_level": null, + "description": null, + "archived": false, + "trashed_from_location": null, + "slug": "sunburst", + "archive_operation_id": null, + "name": "Sunburst", + "personal_owner_id": null, + "type": null, + "is_sample": false, + "id": 32, + "archived_directly": null, + "entity_id": "yivDsLk9XUmdCWr0lt9GJ", + "location": "/5/23/", + "namespace": null, + "is_personal": false, + "created_at": "2024-08-25T18:16:32.499168Z" + }, + "visualization_settings": { + "table.pivot_column": "generation", + "table.cell_column": "count", + "pie.dimension": ["generation", "type_1", "type_2"], + "pie.metric": "count", + "pie.slice_threshold": 0 + }, + "last-edit-info": { + "id": 1, + "email": "emmad@metabase.com", + "first_name": "Emmad", + "last_name": "Usmani", + "timestamp": "2024-09-12T20:57:42.021468Z" + }, + "collection_preview": true, + "entity_id": "ue-evUeQ07b-xZEr48VAk", + "archived_directly": false, + "display": "pie", + "parameter_mappings": [], + "id": 437, + "dataset_query": { + "database": 2, + "type": "query", + "query": { + "aggregation": [["count"]], + "breakout": [ + [ + "field", + "generation", + { + "base-type": "type/BigInteger" + } + ], + [ + "field", + "type_1", + { + "base-type": "type/Text" + } + ], + [ + "field", + "type_2", + { + "base-type": "type/Text" + } + ] + ], + "order-by": [["desc", ["aggregation", 0]]], + "source-table": "card__104", + "filter": [ + "and", + [ + "=", + [ + "field", + "type_1", + { + "base-type": "type/Text" + } + ], + "Water", + "Grass", + "Fire" + ], + [ + "not-empty", + [ + "field", + "type_2", + { + "base-type": "type/Text" + } + ] + ] + ] + } + }, + "cache_ttl": null, + "embedding_params": null, + "made_public_by_id": null, + "updated_at": "2024-09-12T20:57:41.785432Z", + "moderation_reviews": [], + "can_restore": false, + "creator_id": 1, + "average_query_time": 487.7894736842105, + "type": "question", + "last_used_at": "2024-09-13T17:05:33.584358Z", + "dashboard_count": 1, + "last_query_start": "2024-09-13T17:05:33.238198Z", + "name": "Sunburst - Poke Count by Gen, Type 1, Type 2, 0 MSP", + "query_type": "query", + "collection_id": 32, + "enable_embedding": false, + "database_id": 2, + "trashed_from_collection_id": null, + "can_write": true, + "initially_published_at": null, + "creator": { + "email": "emmad@metabase.com", + "first_name": "Emmad", + "last_login": "2024-09-13T17:05:26.388255Z", + "is_qbnewb": false, + "is_superuser": true, + "id": 1, + "last_name": "Usmani", + "date_joined": "2023-11-21T21:25:41.062104Z", + "common_name": "Emmad Usmani" + }, + "result_metadata": [ + { + "semantic_type": "type/Category", + "name": "generation", + "field_ref": [ + "field", + "generation", + { + "base-type": "type/BigInteger" + } + ], + "effective_type": "type/BigInteger", + "id": 1503, + "visibility_type": "normal", + "display_name": "Generation", + "fingerprint": { + "global": { + "distinct-count": 8, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 1, + "q1": 2.1045160333142734, + "q3": 5.716989271844637, + "max": 8, + "sd": 2.234937326790694, + "avg": 4.034046692607004 + } + } + }, + "base_type": "type/BigInteger" + }, + { + "semantic_type": "type/Category", + "name": "type_1", + "field_ref": [ + "field", + "type_1", + { + "base-type": "type/Text" + } + ], + "effective_type": "type/Text", + "id": 1475, + "visibility_type": "normal", + "display_name": "Type 1", + "fingerprint": { + "global": { + "distinct-count": 18, + "nil%": 0 + }, + "type": { + "type/Text": { + "percent-json": 0, + "percent-url": 0, + "percent-email": 0, + "percent-state": 0, + "average-length": 5.281128404669261 + } + } + }, + "base_type": "type/Text" + }, + { + "semantic_type": "type/Category", + "name": "type_2", + "field_ref": [ + "field", + "type_2", + { + "base-type": "type/Text" + } + ], + "effective_type": "type/Text", + "id": 1499, + "visibility_type": "normal", + "display_name": "Type 2", + "fingerprint": { + "global": { + "distinct-count": 19, + "nil%": 0.4727626459143969 + }, + "type": { + "type/Text": { + "percent-json": 0, + "percent-url": 0, + "percent-email": 0, + "percent-state": 0, + "average-length": 2.9474708171206228 + } + } + }, + "base_type": "type/Text" + }, + { + "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": 6, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 1, + "q1": 1, + "q3": 2.2964690541943424, + "max": 10, + "sd": 1.2898977338673652, + "avg": 1.8076923076923077 + } + } + } + } + ], + "can_run_adhoc_query": true, + "table_id": 156, + "source_card_id": 104, + "collection_position": null, + "view_count": 35, + "archived": false, + "description": null, + "cache_invalidated_at": null, + "displayIsLocked": true + }, + "data": { + "rows": [ + [1, "Grass", "Poison", 10], + [3, "Water", "Ground", 5], + [1, "Water", "Psychic", 4], + [3, "Water", "Dark", 4], + [1, "Fire", "Flying", 3], + [1, "Water", "Ice", 3], + [2, "Grass", "Flying", 3], + [3, "Fire", "Fighting", 3], + [3, "Fire", "Ground", 3], + [3, "Grass", "Dark", 3], + [3, "Water", "Grass", 3], + [4, "Grass", "Ice", 3], + [7, "Grass", "Fairy", 3], + [8, "Grass", "Dragon", 3], + [1, "Grass", "Psychic", 2], + [1, "Water", "Poison", 2], + [2, "Water", "Electric", 2], + [2, "Water", "Fairy", 2], + [2, "Water", "Ground", 2], + [3, "Water", "Flying", 2], + [4, "Fire", "Fighting", 2], + [4, "Grass", "Poison", 2], + [5, "Fire", "Fighting", 2], + [5, "Grass", "Fairy", 2], + [5, "Grass", "Poison", 2], + [5, "Grass", "Steel", 2], + [5, "Water", "Fighting", 2], + [5, "Water", "Flying", 2], + [5, "Water", "Ghost", 2], + [5, "Water", "Ground", 2], + [5, "Water", "Rock", 2], + [6, "Fire", "Flying", 2], + [6, "Fire", "Normal", 2], + [6, "Water", "Dark", 2], + [7, "Grass", "Flying", 2], + [7, "Water", "Bug", 2], + [7, "Water", "Fairy", 2], + [8, "Fire", "Bug", 2], + [1, "Fire", "Dragon", 1], + [1, "Fire", "Ghost", 1], + [1, "Grass", "Dragon", 1], + [1, "Water", "Dark", 1], + [1, "Water", "Fighting", 1], + [1, "Water", "Flying", 1], + [2, "Fire", "Flying", 1], + [2, "Fire", "Rock", 1], + [2, "Water", "Dragon", 1], + [2, "Water", "Flying", 1], + [2, "Water", "Poison", 1], + [2, "Water", "Psychic", 1], + [2, "Water", "Rock", 1], + [3, "Grass", "Dragon", 1], + [3, "Grass", "Fighting", 1], + [3, "Grass", "Flying", 1], + [3, "Grass", "Poison", 1], + [3, "Water", "Rock", 1], + [4, "Fire", "Steel", 1], + [4, "Grass", "Flying", 1], + [4, "Grass", "Ground", 1], + [4, "Water", "Dragon", 1], + [4, "Water", "Flying", 1], + [4, "Water", "Ground", 1], + [4, "Water", "Steel", 1], + [5, "Fire", "Psychic", 1], + [5, "Grass", "Fighting", 1], + [6, "Fire", "Psychic", 1], + [6, "Fire", "Water", 1], + [6, "Grass", "Fighting", 1], + [7, "Fire", "Dark", 1], + [7, "Fire", "Dragon", 1], + [7, "Fire", "Flying", 1], + [7, "Fire", "Ghost", 1], + [7, "Grass", "Ghost", 1], + [7, "Grass", "Steel", 1], + [7, "Water", "Psychic", 1], + [8, "Water", "Dragon", 1], + [8, "Water", "Ice", 1], + [8, "Water", "Rock", 1] + ], + "cols": [ + { + "database_type": "int8", + "semantic_type": "type/Category", + "table_id": 156, + "name": "generation", + "source": "breakout", + "field_ref": [ + "field", + "generation", + { + "base-type": "type/BigInteger" + } + ], + "effective_type": "type/BigInteger", + "id": 1503, + "position": 5, + "visibility_type": "normal", + "display_name": "Generation", + "fingerprint": { + "global": { + "distinct-count": 8, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 1, + "q1": 2.1045160333142734, + "q3": 5.716989271844637, + "max": 8, + "sd": 2.234937326790694, + "avg": 4.034046692607004 + } + } + }, + "base_type": "type/BigInteger" + }, + { + "database_type": "varchar", + "semantic_type": "type/Category", + "table_id": 156, + "name": "type_1", + "source": "breakout", + "field_ref": [ + "field", + "type_1", + { + "base-type": "type/Text" + } + ], + "effective_type": "type/Text", + "id": 1475, + "position": 9, + "visibility_type": "normal", + "display_name": "Type 1", + "fingerprint": { + "global": { + "distinct-count": 18, + "nil%": 0 + }, + "type": { + "type/Text": { + "percent-json": 0, + "percent-url": 0, + "percent-email": 0, + "percent-state": 0, + "average-length": 5.281128404669261 + } + } + }, + "base_type": "type/Text" + }, + { + "database_type": "varchar", + "semantic_type": "type/Category", + "table_id": 156, + "name": "type_2", + "source": "breakout", + "field_ref": [ + "field", + "type_2", + { + "base-type": "type/Text" + } + ], + "effective_type": "type/Text", + "id": 1499, + "position": 10, + "visibility_type": "normal", + "display_name": "Type 2", + "fingerprint": { + "global": { + "distinct-count": 19, + "nil%": 0.4727626459143969 + }, + "type": { + "type/Text": { + "percent-json": 0, + "percent-url": 0, + "percent-email": 0, + "percent-state": 0, + "average-length": 2.9474708171206228 + } + } + }, + "base_type": "type/Text" + }, + { + "database_type": "int8", + "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 \"source\".\"generation\" AS \"generation\", \"source\".\"type_1\" AS \"type_1\", \"source\".\"type_2\" AS \"type_2\", COUNT(*) AS \"count\" FROM (SELECT \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"_mb_row_id\" AS \"_mb_row_id\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"pokedex_number\" AS \"pokedex_number\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"name\" AS \"name\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"german_name\" AS \"german_name\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"japanese_name\" AS \"japanese_name\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"generation\" AS \"generation\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"status\" AS \"status\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"species\" AS \"species\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"type_number\" AS \"type_number\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"type_1\" AS \"type_1\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"type_2\" AS \"type_2\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"height_m\" AS \"height_m\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"weight_kg\" AS \"weight_kg\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"abilities_number\" AS \"abilities_number\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"ability_1\" AS \"ability_1\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"ability_2\" AS \"ability_2\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"ability_hidden\" AS \"ability_hidden\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"total_points\" AS \"total_points\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"hp\" AS \"hp\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"attack\" AS \"attack\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"defense\" AS \"defense\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"sp_attack\" AS \"sp_attack\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"sp_defense\" AS \"sp_defense\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"speed\" AS \"speed\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"catch_rate\" AS \"catch_rate\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"base_friendship\" AS \"base_friendship\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"base_experience\" AS \"base_experience\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"growth_rate\" AS \"growth_rate\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"egg_type_number\" AS \"egg_type_number\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"egg_type_1\" AS \"egg_type_1\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"egg_type_2\" AS \"egg_type_2\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"percentage_male\" AS \"percentage_male\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"egg_cycles\" AS \"egg_cycles\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_normal\" AS \"against_normal\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_fire\" AS \"against_fire\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_water\" AS \"against_water\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_electric\" AS \"against_electric\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_grass\" AS \"against_grass\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_ice\" AS \"against_ice\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_fight\" AS \"against_fight\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_poison\" AS \"against_poison\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_ground\" AS \"against_ground\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_flying\" AS \"against_flying\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_psychic\" AS \"against_psychic\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_bug\" AS \"against_bug\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_rock\" AS \"against_rock\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_ghost\" AS \"against_ghost\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_dragon\" AS \"against_dragon\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_dark\" AS \"against_dark\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_steel\" AS \"against_steel\", \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\".\"against_fairy\" AS \"against_fairy\" FROM \"csv_upload_data\".\"csv_upload_pokedex_20231202112932\") AS \"source\" WHERE ((\"source\".\"type_1\" = 'Water') OR (\"source\".\"type_1\" = 'Grass') OR (\"source\".\"type_1\" = 'Fire')) AND (\"source\".\"type_2\" IS NOT NULL) AND ((\"source\".\"type_2\" <> '') OR (\"source\".\"type_2\" IS NULL)) GROUP BY \"source\".\"generation\", \"source\".\"type_1\", \"source\".\"type_2\" ORDER BY \"count\" DESC, \"source\".\"generation\" ASC, \"source\".\"type_1\" ASC, \"source\".\"type_2\" ASC", + "params": null + }, + "dataset": true, + "model": true, + "format-rows?": true, + "results_timezone": "America/Los_Angeles", + "results_metadata": { + "columns": [ + { + "semantic_type": "type/Category", + "name": "generation", + "field_ref": [ + "field", + "generation", + { + "base-type": "type/BigInteger" + } + ], + "effective_type": "type/BigInteger", + "id": 1503, + "visibility_type": "normal", + "display_name": "Generation", + "fingerprint": { + "global": { + "distinct-count": 8, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 1, + "q1": 2.1045160333142734, + "q3": 5.716989271844637, + "max": 8, + "sd": 2.234937326790694, + "avg": 4.034046692607004 + } + } + }, + "base_type": "type/BigInteger" + }, + { + "semantic_type": "type/Category", + "name": "type_1", + "field_ref": [ + "field", + "type_1", + { + "base-type": "type/Text" + } + ], + "effective_type": "type/Text", + "id": 1475, + "visibility_type": "normal", + "display_name": "Type 1", + "fingerprint": { + "global": { + "distinct-count": 18, + "nil%": 0 + }, + "type": { + "type/Text": { + "percent-json": 0, + "percent-url": 0, + "percent-email": 0, + "percent-state": 0, + "average-length": 5.281128404669261 + } + } + }, + "base_type": "type/Text" + }, + { + "semantic_type": "type/Category", + "name": "type_2", + "field_ref": [ + "field", + "type_2", + { + "base-type": "type/Text" + } + ], + "effective_type": "type/Text", + "id": 1499, + "visibility_type": "normal", + "display_name": "Type 2", + "fingerprint": { + "global": { + "distinct-count": 19, + "nil%": 0.4727626459143969 + }, + "type": { + "type/Text": { + "percent-json": 0, + "percent-url": 0, + "percent-email": 0, + "percent-state": 0, + "average-length": 2.9474708171206228 + } + } + }, + "base_type": "type/Text" + }, + { + "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": 6, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 1, + "q1": 1, + "q3": 2.2964690541943424, + "max": 10, + "sd": 1.2898977338673652, + "avg": 1.8076923076923077 + } + } + } + } + ] + }, + "insights": null + } + } +] diff --git a/frontend/src/metabase/static-viz/components/PieChart/stories-data/two-rings.json b/frontend/src/metabase/static-viz/components/PieChart/stories-data/two-rings.json new file mode 100644 index 0000000000000000000000000000000000000000..c11adf12c4d6db34f83fcd748754c4bbfbb21c29 --- /dev/null +++ b/frontend/src/metabase/static-viz/components/PieChart/stories-data/two-rings.json @@ -0,0 +1,521 @@ +[ + { + "card": { + "cache_invalidated_at": null, + "description": null, + "archived": false, + "view_count": 63, + "collection_position": null, + "source_card_id": null, + "table_id": 2, + "can_run_adhoc_query": true, + "result_metadata": [ + { + "description": "The type of product, valid values include: Doohicky, Gadget, Gizmo and Widget", + "semantic_type": "type/Category", + "coercion_strategy": null, + "name": "CATEGORY", + "settings": null, + "fk_target_field_id": null, + "field_ref": [ + "field", + 58, + { + "base-type": "type/Text", + "source-field": 40 + } + ], + "effective_type": "type/Text", + "id": 58, + "visibility_type": "normal", + "display_name": "Product → Category", + "fingerprint": { + "global": { + "distinct-count": 4, + "nil%": 0 + }, + "type": { + "type/Text": { + "percent-json": 0, + "percent-url": 0, + "percent-email": 0, + "percent-state": 0, + "average-length": 6.375 + } + } + }, + "base_type": "type/Text" + }, + { + "description": "The date and time an order was submitted.", + "semantic_type": "type/CreationTimestamp", + "coercion_strategy": null, + "unit": "day-of-week", + "name": "CREATED_AT", + "settings": null, + "fk_target_field_id": null, + "field_ref": [ + "field", + 41, + { + "base-type": "type/DateTime", + "temporal-unit": "day-of-week" + } + ], + "effective_type": "type/Integer", + "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/Integer" + }, + { + "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": 27, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 537, + "q1": 622, + "q3": 717.5, + "max": 763, + "sd": 67.4245806369908, + "avg": 670 + } + } + } + } + ], + "creator": { + "email": "emmad@metabase.com", + "first_name": "Emmad", + "last_login": "2024-09-13T17:05:26.388255Z", + "is_qbnewb": false, + "is_superuser": true, + "id": 1, + "last_name": "Usmani", + "date_joined": "2023-11-21T21:25:41.062104Z", + "common_name": "Emmad Usmani" + }, + "initially_published_at": null, + "can_write": true, + "trashed_from_collection_id": null, + "database_id": 1, + "enable_embedding": false, + "collection_id": 32, + "query_type": "query", + "name": "Sunburst - count of orders by product category, created at day of week", + "last_query_start": "2024-09-13T17:05:37.409414Z", + "dashboard_count": 1, + "last_used_at": "2024-09-13T17:05:37.527665Z", + "type": "question", + "average_query_time": 334.13698630136986, + "creator_id": 1, + "can_restore": false, + "moderation_reviews": [], + "updated_at": "2024-09-13T17:08:01.767581Z", + "made_public_by_id": null, + "embedding_params": null, + "cache_ttl": null, + "dataset_query": { + "database": 1, + "type": "query", + "query": { + "source-table": 2, + "aggregation": [["count"]], + "breakout": [ + [ + "field", + 58, + { + "base-type": "type/Text", + "source-field": 40 + } + ], + [ + "field", + 41, + { + "base-type": "type/DateTime", + "temporal-unit": "day-of-week" + } + ] + ] + } + }, + "id": 428, + "parameter_mappings": [], + "display": "pie", + "archived_directly": false, + "entity_id": "NST44N53kq64YzhQhPZO5", + "collection_preview": true, + "last-edit-info": { + "timestamp": "2024-09-13T17:08:02.559Z", + "id": 1, + "first_name": "Emmad", + "last_name": "Usmani", + "email": "emmad@metabase.com" + }, + "visualization_settings": { + "pie.dimension": ["CREATED_AT", "CATEGORY"], + "pie.metric": "count", + "pie.rows": [ + { + "key": "2", + "name": "Mon", + "originalName": "Monday", + "color": "#227FD2", + "defaultColor": false, + "enabled": true, + "hidden": false, + "isOther": false + }, + { + "key": "3", + "name": "Tue", + "originalName": "Tuesday", + "color": "#689636", + "defaultColor": false, + "enabled": true, + "hidden": false, + "isOther": false + }, + { + "key": "4", + "name": "Wed", + "originalName": "Wednesday", + "color": "#8A5EB0", + "defaultColor": false, + "enabled": true, + "hidden": false, + "isOther": false + }, + { + "key": "5", + "name": "Thur", + "originalName": "Thursday", + "color": "#F7C41F", + "defaultColor": false, + "enabled": true, + "hidden": false, + "isOther": false + }, + { + "key": "6", + "name": "Fri", + "originalName": "Friday", + "color": "#69C8C8", + "defaultColor": false, + "enabled": true, + "hidden": false, + "isOther": false + }, + { + "isOther": false, + "hidden": false, + "enabled": false, + "defaultColor": true, + "color": "#7172AD", + "originalName": "Saturday", + "name": "Saturday", + "key": "7" + }, + { + "isOther": false, + "hidden": false, + "enabled": false, + "defaultColor": true, + "color": "#88BF4D", + "originalName": "Sunday", + "name": "Sunday", + "key": "1" + } + ], + "pie.sort_rows": false, + "column_settings": { + "[\"name\",\"CREATED_AT\"]": { + "date_abbreviate": false + } + } + }, + "collection": { + "authority_level": null, + "description": null, + "archived": false, + "trashed_from_location": null, + "slug": "sunburst", + "archive_operation_id": null, + "name": "Sunburst", + "personal_owner_id": null, + "type": null, + "is_sample": false, + "id": 32, + "archived_directly": null, + "entity_id": "yivDsLk9XUmdCWr0lt9GJ", + "location": "/5/23/", + "namespace": null, + "is_personal": false, + "created_at": "2024-08-25T18:16:32.499168Z" + }, + "metabase_version": "v0.1.33-SNAPSHOT (7222517)", + "parameters": [], + "created_at": "2024-08-30T03:45:00.550597Z", + "parameter_usage_count": 0, + "public_uuid": null, + "can_delete": false + }, + "data": { + "rows": [ + ["Doohickey", 1, 566], + ["Doohickey", 2, 537], + ["Doohickey", 3, 553], + ["Doohickey", 4, 563], + ["Doohickey", 5, 569], + ["Doohickey", 6, 582], + ["Doohickey", 7, 606], + ["Gadget", 1, 672], + ["Gadget", 2, 693], + ["Gadget", 3, 763], + ["Gadget", 4, 638], + ["Gadget", 5, 724], + ["Gadget", 6, 709], + ["Gadget", 7, 740], + ["Gizmo", 1, 717], + ["Gizmo", 2, 696], + ["Gizmo", 3, 680], + ["Gizmo", 4, 704], + ["Gizmo", 5, 685], + ["Gizmo", 6, 662], + ["Gizmo", 7, 640], + ["Widget", 1, 716], + ["Widget", 2, 738], + ["Widget", 3, 699], + ["Widget", 4, 718], + ["Widget", 5, 720], + ["Widget", 6, 709], + ["Widget", 7, 761] + ], + "cols": [ + { + "description": "The type of product, valid values include: Doohicky, Gadget, Gizmo and Widget", + "database_type": "CHARACTER VARYING", + "semantic_type": "type/Category", + "table_id": 1, + "coercion_strategy": null, + "name": "CATEGORY", + "settings": null, + "source": "breakout", + "fk_target_field_id": null, + "fk_field_id": 40, + "field_ref": [ + "field", + 58, + { + "base-type": "type/Text", + "source-field": 40 + } + ], + "effective_type": "type/Text", + "nfc_path": null, + "parent_id": null, + "id": 58, + "position": 3, + "visibility_type": "normal", + "display_name": "Product → Category", + "fingerprint": { + "global": { + "distinct-count": 4, + "nil%": 0 + }, + "type": { + "type/Text": { + "percent-json": 0, + "percent-url": 0, + "percent-email": 0, + "percent-state": 0, + "average-length": 6.375 + } + } + }, + "base_type": "type/Text", + "source_alias": "PRODUCTS__via__PRODUCT_ID" + }, + { + "description": "The date and time an order was submitted.", + "database_type": "INTEGER", + "semantic_type": "type/CreationTimestamp", + "table_id": 2, + "coercion_strategy": null, + "unit": "day-of-week", + "name": "CREATED_AT", + "settings": null, + "source": "breakout", + "fk_target_field_id": null, + "field_ref": [ + "field", + 41, + { + "base-type": "type/DateTime", + "temporal-unit": "day-of-week" + } + ], + "effective_type": "type/Integer", + "nfc_path": null, + "parent_id": null, + "id": 41, + "position": 7, + "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/Integer" + }, + { + "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 \"PRODUCTS__via__PRODUCT_ID\".\"CATEGORY\" AS \"PRODUCTS__via__PRODUCT_ID__CATEGORY\", COALESCE(NULLIF((extract(iso_day_of_week from \"PUBLIC\".\"ORDERS\".\"CREATED_AT\") + 1) % 7, 0), 7) AS \"CREATED_AT\", COUNT(*) AS \"count\" FROM \"PUBLIC\".\"ORDERS\" LEFT JOIN \"PUBLIC\".\"PRODUCTS\" AS \"PRODUCTS__via__PRODUCT_ID\" ON \"PUBLIC\".\"ORDERS\".\"PRODUCT_ID\" = \"PRODUCTS__via__PRODUCT_ID\".\"ID\" GROUP BY \"PRODUCTS__via__PRODUCT_ID\".\"CATEGORY\", COALESCE(NULLIF((extract(iso_day_of_week from \"PUBLIC\".\"ORDERS\".\"CREATED_AT\") + 1) % 7, 0), 7) ORDER BY \"PRODUCTS__via__PRODUCT_ID\".\"CATEGORY\" ASC, COALESCE(NULLIF((extract(iso_day_of_week from \"PUBLIC\".\"ORDERS\".\"CREATED_AT\") + 1) % 7, 0), 7) ASC", + "params": null + }, + "format-rows?": true, + "results_timezone": "America/Los_Angeles", + "results_metadata": { + "columns": [ + { + "description": "The type of product, valid values include: Doohicky, Gadget, Gizmo and Widget", + "semantic_type": "type/Category", + "coercion_strategy": null, + "name": "CATEGORY", + "settings": null, + "fk_target_field_id": null, + "field_ref": [ + "field", + 58, + { + "base-type": "type/Text", + "source-field": 40 + } + ], + "effective_type": "type/Text", + "id": 58, + "visibility_type": "normal", + "display_name": "Product → Category", + "fingerprint": { + "global": { + "distinct-count": 4, + "nil%": 0 + }, + "type": { + "type/Text": { + "percent-json": 0, + "percent-url": 0, + "percent-email": 0, + "percent-state": 0, + "average-length": 6.375 + } + } + }, + "base_type": "type/Text" + }, + { + "description": "The date and time an order was submitted.", + "semantic_type": "type/CreationTimestamp", + "coercion_strategy": null, + "unit": "day-of-week", + "name": "CREATED_AT", + "settings": null, + "fk_target_field_id": null, + "field_ref": [ + "field", + 41, + { + "base-type": "type/DateTime", + "temporal-unit": "day-of-week" + } + ], + "effective_type": "type/Integer", + "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/Integer" + }, + { + "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": 27, + "nil%": 0 + }, + "type": { + "type/Number": { + "min": 537, + "q1": 622, + "q3": 717.5, + "max": 763, + "sd": 67.4245806369908, + "avg": 670 + } + } + } + } + ] + }, + "insights": null + } + } +] diff --git a/frontend/src/metabase/visualizations/components/ChartTooltip/EChartsTooltip/EChartsTooltip.tsx b/frontend/src/metabase/visualizations/components/ChartTooltip/EChartsTooltip/EChartsTooltip.tsx index 2154c1b651d756f55f555b463609083a144e7d4a..a6ba65c316c539b44deef5f30184743f9951f179 100644 --- a/frontend/src/metabase/visualizations/components/ChartTooltip/EChartsTooltip/EChartsTooltip.tsx +++ b/frontend/src/metabase/visualizations/components/ChartTooltip/EChartsTooltip/EChartsTooltip.tsx @@ -13,6 +13,7 @@ export interface EChartsTooltipRow { isFocused?: boolean; isSecondary?: boolean; values: React.ReactNode[]; + key?: string; } export interface EChartsTooltipFooter { @@ -66,11 +67,11 @@ export const EChartsTooltip = ({ })} > <tbody> - {paddedRows.map((row, index) => { + {paddedRows.map(row => { return !row.isSecondary ? ( - <TooltipRow key={index} {...row} /> + <TooltipRow {...row} /> ) : ( - <SecondaryRow key={index} {...row} /> + <SecondaryRow {...row} /> ); })} </tbody> diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingColorPicker/ChartSettingColorPicker.tsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingColorPicker/ChartSettingColorPicker.tsx index 9f2edb02974d32f9ac397b418df962d4f7c35707..69811949a1b9b689e42f6729f7f2878f91490275 100644 --- a/frontend/src/metabase/visualizations/components/settings/ChartSettingColorPicker/ChartSettingColorPicker.tsx +++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingColorPicker/ChartSettingColorPicker.tsx @@ -4,6 +4,7 @@ import type { PillSize } from "metabase/core/components/ColorPill"; import ColorSelector from "metabase/core/components/ColorSelector"; import CS from "metabase/css/core/index.css"; import { getAccentColors } from "metabase/lib/colors/groups"; +import type { AccentColorOptions } from "metabase/lib/colors/types"; interface ChartSettingColorPickerProps { className?: string; @@ -11,6 +12,7 @@ interface ChartSettingColorPickerProps { title?: string; pillSize?: PillSize; onChange?: (newValue: string) => void; + accentColorOptions?: AccentColorOptions; } export const ChartSettingColorPicker = ({ @@ -19,12 +21,13 @@ export const ChartSettingColorPicker = ({ title, pillSize, onChange, + accentColorOptions = { main: true, light: true, dark: true, harmony: false }, }: ChartSettingColorPickerProps) => { return ( <div className={cx(CS.flex, CS.alignCenter, CS.mb1, className)}> <ColorSelector value={value} - colors={getAccentColors()} + colors={getAccentColors(accentColorOptions)} onChange={onChange} pillSize={pillSize} /> diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingOrderedItems/ChartSettingOrderedItems.tsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingOrderedItems/ChartSettingOrderedItems.tsx index fecb0215dcfc58474fd235930a1a7cc056c042b9..6869dd3b4d018956fbcc0cdeb1e5de421e493836 100644 --- a/frontend/src/metabase/visualizations/components/settings/ChartSettingOrderedItems/ChartSettingOrderedItems.tsx +++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingOrderedItems/ChartSettingOrderedItems.tsx @@ -3,11 +3,12 @@ import { useCallback } from "react"; import type { DragEndEvent } 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"; import { ColumnItem } from "../ColumnItem"; -interface SortableItem { +export interface SortableItem { enabled: boolean; color?: string; icon?: IconProps["name"]; @@ -29,6 +30,8 @@ interface ChartSettingOrderedItemsProps<T extends SortableItem> items: T[]; getId: (item: T) => string | number; removeIcon?: IconProps["name"]; + accentColorOptions?: AccentColorOptions; + getItemColor?: (item: SortableItem) => string | undefined; } export function ChartSettingOrderedItems<T extends SortableItem>({ @@ -43,6 +46,8 @@ export function ChartSettingOrderedItems<T extends SortableItem>({ onColorChange, getId, removeIcon, + accentColorOptions, + getItemColor = item => item.color, }: ChartSettingOrderedItemsProps<T>) { const isDragDisabled = items.length < 1; const pointerSensor = useSensor(PointerSensor, { @@ -78,11 +83,12 @@ export function ChartSettingOrderedItems<T extends SortableItem>({ ? (color: string) => onColorChange(item, color) : undefined } - color={item.color} + color={getItemColor(item)} draggable={!isDragDisabled} icon={item.icon} removeIcon={removeIcon} role="listitem" + accentColorOptions={accentColorOptions} /> </Sortable> ) : null, @@ -96,6 +102,8 @@ export function ChartSettingOrderedItems<T extends SortableItem>({ onAdd, onEnable, onColorChange, + accentColorOptions, + getItemColor, ], ); diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingSeriesOrder.tsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingSeriesOrder.tsx index 9e8e0357d4296a77bf7b75c5f7a002413d5063a3..863d2f5e3024301bea12429d35e6c8d2268d7209 100644 --- a/frontend/src/metabase/visualizations/components/settings/ChartSettingSeriesOrder.tsx +++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingSeriesOrder.tsx @@ -5,18 +5,22 @@ import { t } from "ttag"; import _ from "underscore"; import type { DragEndEvent } from "metabase/core/components/Sortable"; +import type { AccentColorOptions } from "metabase/lib/colors/types"; import { NULL_DISPLAY_VALUE } from "metabase/lib/constants"; import { isEmpty } from "metabase/lib/validate"; import { Button, Select } from "metabase/ui"; import type { Series } from "metabase-types/api"; -import { ChartSettingOrderedItems } from "./ChartSettingOrderedItems"; +import { + ChartSettingOrderedItems, + type SortableItem as SortableChartSettingOrderedItem, +} from "./ChartSettingOrderedItems"; import { ChartSettingMessage, ChartSettingOrderedSimpleRoot, } from "./ChartSettingOrderedSimple.styled"; -interface SortableItem { +export interface SortableItem { key: string; enabled: boolean; name: string; @@ -27,8 +31,6 @@ interface SortableItem { interface ChartSettingSeriesOrderProps { onChange: (rows: SortableItem[]) => void; value: SortableItem[]; - addButtonLabel: string; - searchPickerPlaceholder: string; onShowWidget: ( widget: { props: { seriesKey: string } }, ref: HTMLElement | undefined, @@ -37,6 +39,10 @@ interface ChartSettingSeriesOrderProps { hasEditSettings: boolean; onChangeSeriesColor: (seriesKey: string, color: string) => void; onSortEnd: (newItems: SortableItem[]) => void; + accentColorOptions?: AccentColorOptions; + getItemColor?: (item: SortableChartSettingOrderedItem) => string | undefined; + addButtonLabel?: string; + searchPickerPlaceholder?: string; } export const ChartSettingSeriesOrder = ({ @@ -48,6 +54,8 @@ export const ChartSettingSeriesOrder = ({ hasEditSettings = true, onChangeSeriesColor, onSortEnd, + getItemColor, + accentColorOptions, }: ChartSettingSeriesOrderProps) => { const [isSeriesPickerVisible, setSeriesPickerVisible] = useState(false); @@ -137,6 +145,8 @@ export const ChartSettingSeriesOrder = ({ onColorChange={handleColorChange} getId={getId} removeIcon="close" + accentColorOptions={accentColorOptions} + getItemColor={getItemColor} /> {canAddSeries && !isSeriesPickerVisible && ( <Button diff --git a/frontend/src/metabase/visualizations/components/settings/ColumnItem/ColumnItem.tsx b/frontend/src/metabase/visualizations/components/settings/ColumnItem/ColumnItem.tsx index 515a9f2601ad9d60e3045c4f8fd2c935132724b3..f1fd4adfc8ab029866540de9db969f56eef1eb9d 100644 --- a/frontend/src/metabase/visualizations/components/settings/ColumnItem/ColumnItem.tsx +++ b/frontend/src/metabase/visualizations/components/settings/ColumnItem/ColumnItem.tsx @@ -1,3 +1,4 @@ +import type { AccentColorOptions } from "metabase/lib/colors/types"; import type { IconProps } from "metabase/ui"; import { Icon } from "metabase/ui"; @@ -25,6 +26,7 @@ interface ColumnItemProps { onEdit?: (target: HTMLElement) => void; onEnable?: (target: HTMLElement) => void; onColorChange?: (newColor: string) => void; + accentColorOptions?: AccentColorOptions; } const BaseColumnItem = ({ @@ -41,6 +43,7 @@ const BaseColumnItem = ({ onEdit, onEnable, onColorChange, + accentColorOptions, }: ColumnItemProps) => { return ( <ColumnItemRoot @@ -59,6 +62,7 @@ const BaseColumnItem = ({ value={color} onChange={onColorChange} pillSize="small" + accentColorOptions={accentColorOptions} /> )} <ColumnItemContent> diff --git a/frontend/src/metabase/visualizations/echarts/pie/constants.ts b/frontend/src/metabase/visualizations/echarts/pie/constants.ts index 9317c7793b9388015ce987b77a44c5c0ad59d14e..ff7065164ca967036aee32e67772e406ce250e10 100644 --- a/frontend/src/metabase/visualizations/echarts/pie/constants.ts +++ b/frontend/src/metabase/visualizations/echarts/pie/constants.ts @@ -1,5 +1,7 @@ import { t } from "ttag"; +import { NULL_CHAR } from "../cartesian/constants/dataset"; + export const DIMENSIONS = { maxSideLength: 550, padding: { @@ -8,16 +10,20 @@ export const DIMENSIONS = { }, slice: { innerRadiusRatio: 3 / 5, + twoRingInnerRadiusRatio: 2 / 5, + threeRingInnerRadiusRatio: 1 / 4, borderProportion: 360, // 1 degree + twoRingBorderWidth: 1, + threeRingBorderWidth: 0.3, maxFontSize: 20, minFontSize: 14, + multiRingFontSize: 12, label: { fontWeight: 700, padding: 4, }, }, total: { - minWidth: 120, valueFontSize: 22, labelFontSize: 14, fontWeight: 700, @@ -28,6 +34,10 @@ export const SLICE_THRESHOLD = 0.025; // approx 1 degree in percentage export const OTHER_SLICE_MIN_PERCENTAGE = 0.005; -export const OTHER_SLICE_KEY = "___OTHER___"; +export const OTHER_SLICE_KEY = `${NULL_CHAR}___OTHER___`; + +export const OTHER_SLICE_NAME = t`Other`; export const TOTAL_TEXT = t`Total`.toUpperCase(); + +export const OPTION_NAME_SEPERATOR = `–${NULL_CHAR}–`; diff --git a/frontend/src/metabase/visualizations/echarts/pie/format.ts b/frontend/src/metabase/visualizations/echarts/pie/format.ts index c959541d274f4463b28de9f9dbf470f047a13142..57a05c6142edebf83096d647fde9bf5602bc1960 100644 --- a/frontend/src/metabase/visualizations/echarts/pie/format.ts +++ b/frontend/src/metabase/visualizations/echarts/pie/format.ts @@ -1,16 +1,37 @@ +import { NULL_DISPLAY_VALUE } from "metabase/lib/constants"; import { computeMaxDecimalsForValues } from "metabase/visualizations/lib/utils"; import type { ComputedVisualizationSettings, + Formatter, + RemappingHydratedDatasetColumn, RenderingContext, } from "metabase/visualizations/types"; +import type { RowValue } from "metabase-types/api"; -import type { PieChartModel } from "./model/types"; +import type { PieChartModel, SliceTree, SliceTreeNode } from "./model/types"; +import { getArrayFromMapValues } from "./util"; export interface PieChartFormatters { formatMetric: (value: unknown, isCompact?: boolean) => string; formatPercent: (value: unknown, location: "legend" | "chart") => string; } +function getAllSlicePercentages(sliceTree: SliceTree) { + const percentages: number[] = []; + + function getPercentages(node: SliceTreeNode) { + percentages.push(node.normalizedPercentage); + if (node.isOther) { + return; + } + + node.children.forEach(c => getPercentages(c)); + } + sliceTree.forEach(node => getPercentages(node)); + + return percentages; +} + export function getPieChartFormatters( chartModel: PieChartModel, settings: ComputedVisualizationSettings, @@ -34,13 +55,17 @@ export function getPieChartFormatters( const formatPercent = (value: unknown, location: "legend" | "chart") => { let decimals = settings["pie.decimal_places"]; if (decimals == null) { - decimals = computeMaxDecimalsForValues( - chartModel.slices.map(s => s.data.normalizedPercentage), - { - style: "percent", - maximumSignificantDigits: location === "legend" ? 3 : 2, - }, - ); + const percentages = + location === "chart" + ? getAllSlicePercentages(chartModel.sliceTree) + : getArrayFromMapValues(chartModel.sliceTree).map( + s => s.normalizedPercentage, + ); + + decimals = computeMaxDecimalsForValues(percentages, { + style: "percent", + maximumSignificantDigits: location === "legend" ? 3 : 2, + }); } return renderingContext.formatValue(value, { @@ -53,3 +78,24 @@ export function getPieChartFormatters( return { formatMetric, formatPercent }; } + +export function getDimensionFormatter( + settings: ComputedVisualizationSettings, + dimensionColumn: RemappingHydratedDatasetColumn, + formatter: Formatter, +) { + const getColumnSettings = settings["column"]; + if (!getColumnSettings) { + throw Error("`settings.column` is undefined"); + } + + const dimensionColSettings = getColumnSettings(dimensionColumn); + + return (value: RowValue) => { + if (value == null) { + return NULL_DISPLAY_VALUE; + } + + return formatter(value, dimensionColSettings); + }; +} diff --git a/frontend/src/metabase/visualizations/echarts/pie/model/index.ts b/frontend/src/metabase/visualizations/echarts/pie/model/index.ts index 2a852bf71ca15de4a87b47dc60700a00a5509b8c..e7947bd4f5b2d0deeddf50e1c373438c518b6c97 100644 --- a/frontend/src/metabase/visualizations/echarts/pie/model/index.ts +++ b/frontend/src/metabase/visualizations/echarts/pie/model/index.ts @@ -1,28 +1,40 @@ import { pie } from "d3"; -import { t } from "ttag"; import _ from "underscore"; import { findWithIndex } from "metabase/lib/arrays"; import { checkNotNull } from "metabase/lib/types"; +import type { ColumnDescriptor } from "metabase/visualizations/lib/graph/columns"; import { getNumberOr } from "metabase/visualizations/lib/settings/row-values"; -import { pieNegativesWarning } from "metabase/visualizations/lib/warnings"; +import { + pieNegativesWarning, + unaggregatedDataWarningPie, +} from "metabase/visualizations/lib/warnings"; import { getAggregatedRows, getKeyFromDimensionValue, + getPieDimensions, } from "metabase/visualizations/shared/settings/pie"; import type { ComputedVisualizationSettings, RenderingContext, } from "metabase/visualizations/types"; -import type { RawSeries } from "metabase-types/api"; +import type { RawSeries, RowValue } from "metabase-types/api"; import type { ShowWarning } from "../../types"; -import { OTHER_SLICE_KEY, OTHER_SLICE_MIN_PERCENTAGE } from "../constants"; +import { + OTHER_SLICE_KEY, + OTHER_SLICE_MIN_PERCENTAGE, + OTHER_SLICE_NAME, +} from "../constants"; +import { getDimensionFormatter } from "../format"; +import { getArrayFromMapValues } from "../util"; +import { getColorForRing } from "../util/colors"; import type { PieChartModel, PieColumnDescriptors, - PieSliceData, + SliceTree, + SliceTreeNode, } from "./types"; export function getPieColumns( @@ -35,19 +47,18 @@ export function getPieColumns( }, ] = rawSeries; - const dimension = findWithIndex( - cols, - c => c.name === settings["pie.dimension"], - ); const metric = findWithIndex(cols, c => c.name === settings["pie.metric"]); + const dimensionColNames = getPieDimensions(settings); + const dimension = findWithIndex(cols, c => c.name === dimensionColNames[0]); + if (!dimension.item || !metric.item) { throw new Error( `Could not find columns based on "pie.dimension" (${settings["pie.dimension"]}) and "pie.metric" (${settings["pie.metric"]}) settings.`, ); } - return { + const colDescs: PieColumnDescriptors = { dimensionDesc: { index: dimension.index, column: dimension.item, @@ -57,6 +68,177 @@ export function getPieColumns( column: metric.item, }, }; + + if (dimensionColNames.length > 1) { + const middleDimension = findWithIndex( + cols, + c => c.name === dimensionColNames[1], + ); + if (!middleDimension.item) { + throw new Error( + `Could not find column based on "pie.dimension" (${settings["pie.dimension"]})`, + ); + } + + colDescs.middleDimensionDesc = { + index: middleDimension.index, + column: middleDimension.item, + }; + } + + if (dimensionColNames.length > 2) { + const outerDimension = findWithIndex( + cols, + c => c.name === dimensionColNames[2], + ); + if (!outerDimension.item) { + throw new Error( + `Could not find column based on "pie.dimension" (${settings["pie.dimension"]})`, + ); + } + + colDescs.outerDimensionDesc = { + index: outerDimension.index, + column: outerDimension.item, + }; + } + + return colDescs; +} + +function createOrUpdateNode( + metricValue: number, + dimensionValue: RowValue, + colDesc: ColumnDescriptor, + formatter: (rowValue: RowValue) => string, + parentNode: SliceTreeNode, + color: string, + rowIndex: number, + total: number, + showWarning?: ShowWarning, +) { + const dimensionKey = getKeyFromDimensionValue(dimensionValue); + let dimensionNode = parentNode.children.get(String(dimensionKey)); + + if (dimensionNode == null) { + // If there is no node for this dimension value in the tree + // create it. + dimensionNode = { + key: dimensionKey, + name: formatter(dimensionValue), + value: metricValue, + displayValue: metricValue, + normalizedPercentage: metricValue / total, + color, + visible: true, + column: colDesc.column, + rowIndex, + isOther: false, + children: new Map(), + startAngle: 0, + endAngle: 0, + }; + parentNode.children.set(dimensionKey, dimensionNode); + } else { + // If the node already exists, add the metric value from the current row + // to it. + dimensionNode.value += metricValue; + dimensionNode.displayValue += metricValue; + dimensionNode.normalizedPercentage = dimensionNode.value / total; + + showWarning?.(unaggregatedDataWarningPie(colDesc.column).text); + } + + return dimensionNode; +} + +function markOtherNodes( + node: SliceTreeNode, + parent: SliceTreeNode, + settings: ComputedVisualizationSettings, +) { + node.isOther = + node.displayValue / parent.displayValue < + (settings["pie.slice_threshold"] ?? 0) / 100; + + node.children.forEach(child => markOtherNodes(child, node, settings)); +} + +function aggregateSlices( + node: SliceTreeNode, + total: number, + renderingContext: RenderingContext, +) { + const children = getArrayFromMapValues(node.children); + const others = children.filter(s => s.isOther); + const otherTotal = others.reduce((currTotal, o) => currTotal + o.value, 0); + + if (others.length > 1 && otherTotal > 0) { + const otherSliceChildren: SliceTree = new Map(); + others.forEach(o => { + otherSliceChildren.set(String(o.key), { ...o, color: "" }); + node.children.delete(String(o.key)); + }); + + node.children.set(OTHER_SLICE_KEY, { + key: OTHER_SLICE_KEY, + name: OTHER_SLICE_NAME, + value: otherTotal, + displayValue: otherTotal, + normalizedPercentage: otherTotal / total, + color: renderingContext.getColor("text-light"), + children: otherSliceChildren, + visible: true, + isOther: true, + startAngle: 0, + endAngle: 0, + }); + } else if (others.length === 1) { + others[0].isOther = false; + } + + children.forEach(child => aggregateSlices(child, total, renderingContext)); +} + +function computeSliceAngles( + slices: SliceTreeNode[], + startAngle?: number, + endAngle?: number, +) { + const d3Pie = pie<SliceTreeNode>() + .sort(null) + // 1 degree in radians + .padAngle((Math.PI / 180) * 1) + .startAngle(startAngle ?? 0) + .endAngle(endAngle ?? 2 * Math.PI) + .value(s => s.value); + + const d3Slices = d3Pie(slices, { startAngle, endAngle }); + d3Slices.forEach((d3Slice, index) => { + slices[index].startAngle = d3Slice.startAngle; + slices[index].endAngle = d3Slice.endAngle; + }); + + slices.forEach(slice => + computeSliceAngles( + getArrayFromMapValues(slice.children), + slice.startAngle, + slice.endAngle, + ), + ); +} + +function countNumRings(node: SliceTreeNode, numRings = 0): number { + if (node.isOther) { + return numRings + 1; + } + + return Math.max( + ...getArrayFromMapValues(node.children).map(node => + countNumRings(node, numRings + 1), + ), + numRings + 1, + ); } export function getPieChartModel( @@ -87,7 +269,7 @@ export function getPieChartModel( dataRows, colDescs.dimensionDesc.index, colDescs.metricDesc.index, - showWarning, + colDescs.middleDimensionDesc == null ? showWarning : undefined, colDescs.dimensionDesc.column, ); @@ -123,15 +305,8 @@ export function getPieChartModel( : !hiddenSlices.includes(row.key), ); - // We allow negative values if every single metric value is negative or 0 - // (`isNonPositive` = true). If the values are mixed between positives and - // negatives, we'll simply ignore the negatives in all calculations. - const isNonPositive = - visiblePieRows.every(row => row.value <= 0) && - !visiblePieRows.every(row => row.value === 0); - const total = visiblePieRows.reduce((currTotal, { value }) => { - if (!isNonPositive && value < 0) { + if (value < 0) { showWarning?.(pieNegativesWarning().text); return currTotal; } @@ -139,26 +314,38 @@ export function getPieChartModel( return currTotal + value; }, 0); - const [slices, others] = _.chain(pieRowsWithValues) - .map(({ value, color, key, name, isOther }): PieSliceData => { + // Create sliceTree, fill out the innermost slice ring + const sliceTree: SliceTree = new Map(); + const [sliceTreeNodes, others] = _.chain(pieRowsWithValues) + .map(({ value, color, key, name, isOther }, index) => { const visible = isOther ? !hiddenSlices.includes(OTHER_SLICE_KEY) : !hiddenSlices.includes(key); + return { key, name, - value: isNonPositive ? -1 * value : value, + value, displayValue: value, normalizedPercentage: visible ? value / total : 0, // slice percentage values are normalized to 0-1 scale - rowIndex: rowIndiciesByKey.get(key), - color, + color: getColorForRing( + color, + "inner", + colDescs.middleDimensionDesc != null, + renderingContext, + ), visible, + children: new Map(), + column: colDescs.dimensionDesc.column, + rowIndex: checkNotNull(rowIndiciesByKey.get(key)), + legendHoverIndex: index, isOther, - noHover: false, includeInLegend: true, + startAngle: 0, // placeholders + endAngle: 0, }; }) - .filter(slice => isNonPositive || slice.value > 0) + .filter(slice => slice.value > 0) .partition(slice => slice != null && !slice.isOther) .value(); @@ -166,65 +353,166 @@ export function getPieChartModel( // group into it if (others.length === 1) { const singleOtherSlice = others.pop(); - slices.push(checkNotNull(singleOtherSlice)); + sliceTreeNodes.push(checkNotNull(singleOtherSlice)); + } + + sliceTreeNodes.forEach(node => { + // Map key needs to be string, because we use it for lookup with values from + // echarts, and echarts casts numbers to strings + sliceTree.set(String(node.key), node); + }); + + // Iterate through non-aggregated rows from query result to build layers for + // the middle and outer ring slices. + if (colDescs.middleDimensionDesc != null) { + const formatMiddleDimensionValue = getDimensionFormatter( + settings, + colDescs.middleDimensionDesc.column, + renderingContext.formatValue, + ); + + const formatOuterDimensionValue = + colDescs.outerDimensionDesc?.column != null + ? getDimensionFormatter( + settings, + colDescs.outerDimensionDesc.column, + renderingContext.formatValue, + ) + : undefined; + + dataRows.forEach((row, index) => { + // Needed to tell typescript it's defined + if (colDescs.middleDimensionDesc == null) { + throw new Error(`Missing middleDimensionDesc`); + } + + const dimensionNode = sliceTree.get( + getKeyFromDimensionValue(row[colDescs.dimensionDesc.index]), + ); + const dimensionIsOther = dimensionNode == null; + if (dimensionIsOther) { + return; + } + const metricValue = getNumberOr(row[colDescs.metricDesc.index], 0); + if (metricValue < 0) { + return; + } + + // Create or update node for middle dimension + const middleDimensionNode = createOrUpdateNode( + metricValue, + row[colDescs.middleDimensionDesc.index], + colDescs.middleDimensionDesc, + formatMiddleDimensionValue, + dimensionNode, + getColorForRing(dimensionNode.color, "middle", true, renderingContext), + index, + total, + colDescs.outerDimensionDesc == null ? showWarning : undefined, + ); + + if ( + colDescs.outerDimensionDesc == null || + formatOuterDimensionValue == null + ) { + return; + } + + // Create or update node for outer dimension + createOrUpdateNode( + metricValue, + row[colDescs.outerDimensionDesc.index], + colDescs.outerDimensionDesc, + formatOuterDimensionValue, + middleDimensionNode, + getColorForRing(dimensionNode.color, "outer", true, renderingContext), + index, + total, + showWarning, + ); + }); } + sliceTree.forEach(node => + node.children.forEach(child => markOtherNodes(child, node, settings)), + ); + // Only add "other" slice if there are slices below threshold with non-zero total const otherTotal = others.reduce((currTotal, o) => currTotal + o.value, 0); if (otherTotal > 0) { + const children: SliceTree = new Map(); + others.forEach(node => { + children.set(String(node.key), { + ...node, + color: "", + }); + }); const visible = !hiddenSlices.includes(OTHER_SLICE_KEY); - slices.push({ + + sliceTree.set(OTHER_SLICE_KEY, { key: OTHER_SLICE_KEY, - name: t`Other`, + name: OTHER_SLICE_NAME, value: otherTotal, displayValue: otherTotal, normalizedPercentage: visible ? otherTotal / total : 0, color: renderingContext.getColor("text-light"), + column: colDescs.dimensionDesc.column, visible, - isOther: true, - noHover: false, + children, + legendHoverIndex: sliceTree.size, includeInLegend: true, + isOther: true, + startAngle: 0, + endAngle: 0, }); } - slices.forEach(slice => { - // We increase the size of small slices, otherwise they will not be visible - // in echarts due to the border rendering over the tiny slice - if ( - slice.visible && - slice.normalizedPercentage < OTHER_SLICE_MIN_PERCENTAGE - ) { + // Aggregate slices in middle and outer ring into "other" slices + sliceTreeNodes.forEach(node => + aggregateSlices(node, total, renderingContext), + ); + + // We increase the size of small slices, but only for the first ring, because + // if we do this for the outer rings, it can lead to overlapping slices. + sliceTree.forEach(slice => { + if (slice.normalizedPercentage < OTHER_SLICE_MIN_PERCENTAGE) { slice.value = total * OTHER_SLICE_MIN_PERCENTAGE; } }); + // We need start and end angles for the label formatter, to determine if we + // should the percent label on the chart for a specific slice. To get these we + // need to use d3. + computeSliceAngles(getArrayFromMapValues(sliceTree)); + // If there are no non-zero slices, we'll display a single "other" slice - if (slices.length === 0) { - slices.push({ + if (sliceTree.size === 0) { + sliceTree.set(OTHER_SLICE_KEY, { key: OTHER_SLICE_KEY, - name: t`Other`, + name: OTHER_SLICE_NAME, value: 1, displayValue: 0, normalizedPercentage: 0, color: renderingContext.getColor("text-light"), visible: true, + column: colDescs.dimensionDesc.column, + children: new Map(), + legendHoverIndex: 0, isOther: true, noHover: true, includeInLegend: false, + startAngle: 0, + endAngle: 2 * Math.PI, }); } - // We need d3 slices for the label formatter, to determine if we should the - // percent label on the chart for a specific slice - const d3Pie = pie<PieSliceData>() - .sort(null) - // 1 degree in radians - .padAngle((Math.PI / 180) * 1) - .value(s => s.value); + const numRings = Math.max( + ...getArrayFromMapValues(sliceTree).map(node => countNumRings(node)), + ); return { - slices: d3Pie(slices), - otherSlices: d3Pie(others), + sliceTree, + numRings, total, colDescs, }; diff --git a/frontend/src/metabase/visualizations/echarts/pie/model/types.ts b/frontend/src/metabase/visualizations/echarts/pie/model/types.ts index bea9cfc6a6a906a1d73c7f5a43f64f0d288d68ab..a110c1f275dd36668fcb5ff43a72e2be86639967 100644 --- a/frontend/src/metabase/visualizations/echarts/pie/model/types.ts +++ b/frontend/src/metabase/visualizations/echarts/pie/model/types.ts @@ -1,9 +1,8 @@ -import type { PieArcDatum } from "d3"; - import type { ColumnDescriptor } from "metabase/visualizations/lib/graph/columns"; +import type { RemappingHydratedDatasetColumn } from "metabase/visualizations/types"; export interface PieRow { - key: string | number; + key: string; name: string; originalName: string; color: string; @@ -16,27 +15,34 @@ export interface PieRow { export interface PieColumnDescriptors { metricDesc: ColumnDescriptor; dimensionDesc: ColumnDescriptor; + middleDimensionDesc?: ColumnDescriptor; + outerDimensionDesc?: ColumnDescriptor; } -export interface PieSliceData { - key: string | number; // dimension value, used to lookup slices +export type SliceTreeNode = { + key: string; name: string; // display name, already formatted value: number; // size of the slice used for rendering displayValue: number; // real metric value of the slice displayed in tooltip or total graphic normalizedPercentage: number; visible: boolean; color: string; - isOther: boolean; - noHover: boolean; - includeInLegend: boolean; + startAngle: number; + endAngle: number; + children: SliceTree; + column?: RemappingHydratedDatasetColumn; rowIndex?: number; -} + legendHoverIndex?: number; + isOther?: boolean; + noHover?: boolean; + includeInLegend?: boolean; +}; -export type PieSlice = PieArcDatum<PieSliceData>; +export type SliceTree = Map<string, SliceTreeNode>; export interface PieChartModel { - slices: PieSlice[]; - otherSlices: PieSlice[]; + sliceTree: SliceTree; total: number; + numRings: number; colDescs: PieColumnDescriptors; } diff --git a/frontend/src/metabase/visualizations/echarts/pie/option.ts b/frontend/src/metabase/visualizations/echarts/pie/option.ts index 4a29b93df6b1658a30ce4f2c210d9c436f6a845d..a516e70cae505f0793a050811f1195432b0ff684 100644 --- a/frontend/src/metabase/visualizations/echarts/pie/option.ts +++ b/frontend/src/metabase/visualizations/echarts/pie/option.ts @@ -1,29 +1,18 @@ import Color from "color"; -import type { EChartsOption } from "echarts"; +import type { EChartsOption, SunburstSeriesOption } from "echarts"; import { getTextColorForBackground } from "metabase/lib/colors"; +import { checkNotNull } from "metabase/lib/types"; import { truncateText } from "metabase/visualizations/lib/text"; import type { ComputedVisualizationSettings, RenderingContext, } from "metabase/visualizations/types"; -import { DIMENSIONS, TOTAL_TEXT } from "./constants"; +import { DIMENSIONS, OPTION_NAME_SEPERATOR, TOTAL_TEXT } from "./constants"; import type { PieChartFormatters } from "./format"; -import type { PieChartModel, PieSlice, PieSliceData } from "./model/types"; - -function getSliceByKey(key: PieSliceData["key"], slices: PieSlice[]) { - const slice = slices.find(s => s.data.key === key); - if (!slice) { - throw Error( - `Could not find slice with key ${key} in slices: ${JSON.stringify( - slices, - )}`, - ); - } - - return slice; -} +import type { PieChartModel, SliceTreeNode } from "./model/types"; +import { getArrayFromMapValues, getSliceTreeNodesFromPath } from "./util"; function getTotalGraphicOption( settings: ComputedVisualizationSettings, @@ -31,49 +20,80 @@ function getTotalGraphicOption( formatters: PieChartFormatters, renderingContext: RenderingContext, hoveredIndex: number | undefined, + hoveredSliceKeyPath: string[] | undefined, outerRadius: number, + innerRadius: number, ) { + // The font size is technically incorrect for the label text since it uses a + // smaller font than the value, however using the value font size for + // measurements makes up for the inaccuracy of our heuristic and provided a + // good end result. + const fontStyle = { + size: DIMENSIONS.total.valueFontSize, + weight: DIMENSIONS.total.fontWeight, + family: renderingContext.fontFamily, + }; + let valueText = ""; let labelText = ""; - // Don't display any text if there isn't enough width - const hasSufficientWidth = outerRadius * 2 >= DIMENSIONS.total.minWidth; + const defaultLabelWillOverflow = + renderingContext.measureText(TOTAL_TEXT, fontStyle) >= innerRadius * 2; + + if (settings["pie.show_total"] && !defaultLabelWillOverflow) { + let sliceValueOrTotal = 0; + + // chart hovered + if (hoveredSliceKeyPath != null) { + const { sliceTreeNode } = getSliceTreeNodesFromPath( + chartModel.sliceTree, + hoveredSliceKeyPath, + ); + + sliceValueOrTotal = checkNotNull(sliceTreeNode).displayValue; + labelText = checkNotNull(sliceTreeNode?.name); + + // legend hovered + } else if (hoveredIndex != null) { + const slice = getArrayFromMapValues(chartModel.sliceTree)[hoveredIndex]; - if (hasSufficientWidth && settings["pie.show_total"]) { - const sliceValueOrTotal = - hoveredIndex != null - ? chartModel.slices[hoveredIndex].data.displayValue - : chartModel.total; + sliceValueOrTotal = slice.displayValue; + labelText = slice.name.toUpperCase(); + } else { + sliceValueOrTotal = chartModel.total; + labelText = TOTAL_TEXT; + } const valueWillOverflow = - renderingContext.measureText(formatters.formatMetric(sliceValueOrTotal), { - size: DIMENSIONS.total.valueFontSize, - family: renderingContext.fontFamily, - weight: DIMENSIONS.total.fontWeight, - }) > outerRadius; // innerRadius technically makes more sense, but looks too narrow in practice - - const fontStyle = { - size: DIMENSIONS.total.valueFontSize, - weight: DIMENSIONS.total.fontWeight, - family: renderingContext.fontFamily, - }; + renderingContext.measureText( + formatters.formatMetric(sliceValueOrTotal), + fontStyle, + ) > outerRadius; // innerRadius technically makes more sense, but looks too narrow in practice ; valueText = truncateText( formatters.formatMetric(sliceValueOrTotal, valueWillOverflow), - outerRadius, + innerRadius * 2, renderingContext.measureText, fontStyle, ); labelText = truncateText( - hoveredIndex != null - ? chartModel.slices[hoveredIndex].data.name.toUpperCase() - : TOTAL_TEXT, - outerRadius, + labelText, + innerRadius * 2, renderingContext.measureText, fontStyle, ); } + const valueTextWidth = renderingContext.measureText(valueText, fontStyle); + const labelTextWidth = renderingContext.measureText(labelText, fontStyle); + const totalWidth = Math.max(valueTextWidth, labelTextWidth); + + const hasSufficientWidth = innerRadius * 2 >= totalWidth; + if (!hasSufficientWidth) { + valueText = ""; + labelText = ""; + } + return { type: "group", top: "center", @@ -110,20 +130,53 @@ function getTotalGraphicOption( }; } -function getRadiusOption(sideLength: number) { +function getRadiusOption(sideLength: number, chartModel: PieChartModel) { + let innerRadiusRatio = DIMENSIONS.slice.innerRadiusRatio; + if (chartModel.numRings === 2) { + innerRadiusRatio = DIMENSIONS.slice.twoRingInnerRadiusRatio; + } else if (chartModel.numRings === 3) { + innerRadiusRatio = DIMENSIONS.slice.threeRingInnerRadiusRatio; + } + const outerRadius = sideLength / 2; - const innerRadius = outerRadius * DIMENSIONS.slice.innerRadiusRatio; + const innerRadius = outerRadius * innerRadiusRatio; return { outerRadius, innerRadius }; } +function getSliceLabel( + slice: SliceTreeNode, + settings: ComputedVisualizationSettings, + formatters: PieChartFormatters, +) { + const name = settings["pie.show_labels"] ? slice.name : undefined; + const percent = + settings["pie.percent_visibility"] === "inside" || + settings["pie.percent_visibility"] === "both" + ? formatters.formatPercent(slice.normalizedPercentage, "chart") + : undefined; + + if (name != null && percent != null) { + return `${name}: ${percent}`; + } + if (name != null) { + return name; + } + if (percent != null) { + return percent; + } + return " "; +} + function getIsLabelVisible( label: string, - slice: PieSlice, + slice: SliceTreeNode, innerRadius: number, outerRadius: number, fontSize: number, renderingContext: RenderingContext, + ring: number, + numRings: number, ) { // We use the law of cosines to determine the length of the chord with the // same endpoints as the arc. The label should be shorter than this chord, and @@ -134,11 +187,13 @@ function getIsLabelVisible( let arcAngle = slice.startAngle - slice.endAngle; arcAngle = Math.min(Math.abs(arcAngle), Math.PI - 0.001); + const donutWidth = (outerRadius - innerRadius) / numRings; + const ringInnerRadius = innerRadius + donutWidth * (ring - 1); + const innerCircleChordLength = Math.sqrt( - 2 * innerRadius * innerRadius - - 2 * innerRadius * innerRadius * Math.cos(arcAngle), + 2 * ringInnerRadius * ringInnerRadius - + 2 * ringInnerRadius * ringInnerRadius * Math.cos(arcAngle), ); - const donutWidth = outerRadius - innerRadius; const maxLabelDimension = Math.min(innerCircleChordLength, donutWidth); const fontStyle = { @@ -149,70 +204,52 @@ function getIsLabelVisible( const labelWidth = renderingContext.measureText(label, fontStyle); const labelHeight = renderingContext.measureTextHeight(label, fontStyle); + if (ring === 1) { + return ( + labelWidth + DIMENSIONS.slice.label.padding <= maxLabelDimension && + labelHeight + DIMENSIONS.slice.label.padding <= maxLabelDimension + ); + } + return ( - labelWidth + DIMENSIONS.slice.label.padding <= maxLabelDimension && - labelHeight + DIMENSIONS.slice.label.padding <= maxLabelDimension + labelWidth + DIMENSIONS.slice.label.padding <= donutWidth && + labelHeight + DIMENSIONS.slice.label.padding <= innerCircleChordLength ); } -export function getPieChartOption( +function getSeriesDataFromSlices( chartModel: PieChartModel, - formatters: PieChartFormatters, settings: ComputedVisualizationSettings, + formatters: PieChartFormatters, renderingContext: RenderingContext, - sideLength: number, - hoveredIndex?: number, -): EChartsOption { - // Sizing - const innerSideLength = Math.min( - sideLength - DIMENSIONS.padding.side * 2, - DIMENSIONS.maxSideLength, - ); - const { outerRadius, innerRadius } = getRadiusOption(innerSideLength); - - const borderWidth = - (Math.PI * innerSideLength) / DIMENSIONS.slice.borderProportion; // arc length formula: s = 2πr(θ/360°), we want border to be 1 degree - - const fontSize = Math.max( - DIMENSIONS.slice.maxFontSize * (innerSideLength / DIMENSIONS.maxSideLength), - DIMENSIONS.slice.minFontSize, - ); - - // "Show total" setting - const graphicOption = getTotalGraphicOption( - settings, - chartModel, - formatters, - renderingContext, - hoveredIndex, - outerRadius, - ); - - // "Show percentages: On the chart" setting - const formatSlicePercent = (key: PieSliceData["key"]) => { - if ( - settings["pie.percent_visibility"] == null || - settings["pie.percent_visibility"] === "off" || - settings["pie.percent_visibility"] === "legend" - ) { - return " "; + borderWidth: number, + innerRadius: number, + outerRadius: number, + fontSize: number, +): SunburstSeriesOption["data"] { + function getSeriesData( + slices: SliceTreeNode[], + ring = 1, + parentName: string | null = null, + ): SunburstSeriesOption["data"] { + if (slices.length === 0) { + return []; } - return formatters.formatPercent( - getSliceByKey(key, chartModel.slices).data.normalizedPercentage, - "chart", - ); - }; + let ringBorderWidth = borderWidth; + if (ring === 2) { + ringBorderWidth = DIMENSIONS.slice.twoRingBorderWidth; + } + if (ring === 3) { + ringBorderWidth = DIMENSIONS.slice.threeRingBorderWidth; + } - // Series data - const data = chartModel.slices - .filter(s => s.data.visible) - .map(s => { + return slices.map(s => { const labelColor = getTextColorForBackground( - s.data.color, + s.color, renderingContext.getColor, ); - const label = formatSlicePercent(s.data.key); + const label = getSliceLabel(s, settings, formatters); const isLabelVisible = getIsLabelVisible( label, s, @@ -220,19 +257,30 @@ export function getPieChartOption( outerRadius, fontSize, renderingContext, + ring, + chartModel.numRings, ); + const name = + parentName != null + ? `${parentName}${OPTION_NAME_SEPERATOR}${s.key}` + : s.key; + return { - value: s.data.value, - name: s.data.name, - itemStyle: { color: s.data.color }, + children: !s.isOther + ? getSeriesData(getArrayFromMapValues(s.children), ring + 1, name) + : undefined, + value: s.value, + name, + itemStyle: { color: s.color, borderWidth: ringBorderWidth }, label: { color: labelColor, formatter: () => (isLabelVisible ? label : " "), + rotate: ring === 1 ? 0 : "radial", }, emphasis: { itemStyle: { - color: s.data.color, + color: s.color, borderColor: renderingContext.theme.pie.borderColor, }, }, @@ -243,7 +291,7 @@ export function getPieChartOption( // causing the underlying color to leak. It is safe to use non-hex // values here, since this value will never be used in batik // (there's no emphasis/blur for static viz). - color: Color(s.data.color).fade(0.7).rgb().string(), + color: Color(s.color).fade(0.7).rgb().string(), opacity: 1, }, label: { @@ -253,6 +301,67 @@ export function getPieChartOption( }, }; }); + } + + return getSeriesData( + getArrayFromMapValues(chartModel.sliceTree).filter(s => s.visible), + ); +} + +export function getPieChartOption( + chartModel: PieChartModel, + formatters: PieChartFormatters, + settings: ComputedVisualizationSettings, + renderingContext: RenderingContext, + sideLength: number, + hoveredIndex?: number, + hoveredSliceKeyPath?: string[], +): EChartsOption { + // Sizing + const innerSideLength = Math.min( + sideLength - DIMENSIONS.padding.side * 2, + DIMENSIONS.maxSideLength, + ); + const { outerRadius, innerRadius } = getRadiusOption( + innerSideLength, + chartModel, + ); + + const borderWidth = + (Math.PI * innerSideLength) / DIMENSIONS.slice.borderProportion; // arc length formula: s = 2πr(θ/360°), we want border to be 1 degree + + const fontSize = + chartModel.numRings > 1 + ? DIMENSIONS.slice.multiRingFontSize + : Math.max( + DIMENSIONS.slice.maxFontSize * + (innerSideLength / DIMENSIONS.maxSideLength), + DIMENSIONS.slice.minFontSize, + ); + + // "Show total" setting + const graphicOption = getTotalGraphicOption( + settings, + chartModel, + formatters, + renderingContext, + hoveredIndex, + hoveredSliceKeyPath, + outerRadius, + innerRadius, + ); + + // Series data + const data = getSeriesDataFromSlices( + chartModel, + settings, + formatters, + renderingContext, + borderWidth, + innerRadius, + outerRadius, + fontSize, + ); return { // Unlike the cartesian chart, `animationDuration: 0` does not prevent the @@ -269,11 +378,9 @@ export function getPieChartOption( nodeClick: false, radius: [innerRadius, outerRadius], itemStyle: { - borderWidth, borderColor: renderingContext.theme.pie.borderColor, }, label: { - rotate: 0, overflow: "none", fontSize, fontWeight: DIMENSIONS.slice.label.fontWeight, @@ -281,6 +388,9 @@ export function getPieChartOption( labelLayout: { hideOverlap: true, }, + emphasis: { + focus: "ancestor", + }, data, }, }; diff --git a/frontend/src/metabase/visualizations/echarts/pie/tooltip.tsx b/frontend/src/metabase/visualizations/echarts/pie/tooltip.tsx index 85edaaf18b732e11f65a455e269696cc4fc29c85..45605c7513006f37b0652cb4b1b571bec958e44e 100644 --- a/frontend/src/metabase/visualizations/echarts/pie/tooltip.tsx +++ b/frontend/src/metabase/visualizations/echarts/pie/tooltip.tsx @@ -8,19 +8,20 @@ import { getTooltipBaseOption } from "../tooltip"; import type { PieChartFormatters } from "./format"; import type { PieChartModel } from "./model/types"; +import { getSliceKeyPath } from "./util"; interface ChartItemTooltip { chartModel: PieChartModel; formatters: PieChartFormatters; - dataIndex: number; + sliceKeyPath: string[]; } const ChartItemTooltip = ({ chartModel, formatters, - dataIndex, + sliceKeyPath, }: ChartItemTooltip) => { - const tooltipModel = getTooltipModel(dataIndex, chartModel, formatters); + const tooltipModel = getTooltipModel(sliceKeyPath, chartModel, formatters); return <EChartsTooltip {...tooltipModel} />; }; @@ -36,11 +37,15 @@ export const getTooltipOption = ( if (Array.isArray(params) || typeof params.dataIndex !== "number") { return ""; } + // @ts-expect-error - `treePathInfo` is present at runtime, but is not in + // the type provided by ECharts. + const sliceKeyPath = getSliceKeyPath(params); + return renderToString( <ChartItemTooltip formatters={formatters} chartModel={chartModel} - dataIndex={params.dataIndex} + sliceKeyPath={sliceKeyPath} />, ); }, diff --git a/frontend/src/metabase/visualizations/echarts/pie/types.ts b/frontend/src/metabase/visualizations/echarts/pie/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..81b4e9298857368ebddaa831f6d1080724b04437 --- /dev/null +++ b/frontend/src/metabase/visualizations/echarts/pie/types.ts @@ -0,0 +1,11 @@ +import type { EChartsSeriesMouseEvent } from "../types"; + +type TreePathInfo = { + name: string; + dataIndex: number; + value: number; +}; + +export type EChartsSunburstSeriesMouseEvent = EChartsSeriesMouseEvent & { + treePathInfo: TreePathInfo[]; +}; diff --git a/frontend/src/metabase/visualizations/echarts/pie/util/colors.ts b/frontend/src/metabase/visualizations/echarts/pie/util/colors.ts new file mode 100644 index 0000000000000000000000000000000000000000..314572bafb20f444f1ce1f9f972d5f7d5389d4ba --- /dev/null +++ b/frontend/src/metabase/visualizations/echarts/pie/util/colors.ts @@ -0,0 +1,83 @@ +import { aliases, colors } from "metabase/lib/colors"; +import { checkNumber } from "metabase/lib/types"; +import type { + ColorGetter, + RenderingContext, +} from "metabase/visualizations/types"; + +const ACCENT_KEY_PREFIX = "accent"; + +function getAccentNumberFromHex(hexColor: string) { + const hexToAccentNumber = new Map<string, number>(); + + for (const [key, hex] of Object.entries(colors)) { + if (!key.startsWith(ACCENT_KEY_PREFIX)) { + continue; + } + + const accentNumber = checkNumber( + Number(key.slice(ACCENT_KEY_PREFIX.length)), + ); + + hexToAccentNumber.set(hex, accentNumber); + } + + for (const [key, hexGetter] of Object.entries(aliases)) { + if (!key.startsWith(ACCENT_KEY_PREFIX)) { + continue; + } + + const accentNumber = checkNumber( + Number(key.slice(ACCENT_KEY_PREFIX.length, ACCENT_KEY_PREFIX.length + 1)), + ); + const hex = hexGetter(colors); + + hexToAccentNumber.set(hex, accentNumber); + } + + return hexToAccentNumber.get(hexColor); +} + +export function getColorForRing( + hexColor: string, + ring: "inner" | "middle" | "outer", + hasMultipleRings: boolean, + renderingContext: RenderingContext, +) { + if (!hasMultipleRings) { + return hexColor; + } + + const accentNumber = getAccentNumberFromHex(hexColor); + if (accentNumber == null) { + return hexColor; + } + + let suffix = ""; + if (ring === "inner") { + suffix = "-dark"; + } else if (ring === "outer") { + suffix = "-light"; + } + + return renderingContext.getColor( + `${ACCENT_KEY_PREFIX}${accentNumber}${suffix}`, + ); +} + +export function getColorForPicker( + hexColor: string | undefined, + hasMultipleRings: boolean, + getColor: ColorGetter, +) { + if (!hasMultipleRings || hexColor == null) { + return hexColor; + } + + const accentNumber = getAccentNumberFromHex(hexColor); + if (accentNumber == null) { + return hexColor; + } + + return getColor(`${ACCENT_KEY_PREFIX}${accentNumber}-dark`); +} diff --git a/frontend/src/metabase/visualizations/echarts/pie/util/index.ts b/frontend/src/metabase/visualizations/echarts/pie/util/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..ab2a525ff8b7b1b4cc5158e4797e27e841cb681c --- /dev/null +++ b/frontend/src/metabase/visualizations/echarts/pie/util/index.ts @@ -0,0 +1,29 @@ +import { checkNotNull } from "metabase/lib/types"; + +import { OPTION_NAME_SEPERATOR } from "../constants"; +import type { SliceTree, SliceTreeNode } from "../model/types"; +import type { EChartsSunburstSeriesMouseEvent } from "../types"; + +export const getSliceKeyPath = (event: EChartsSunburstSeriesMouseEvent) => + event?.name?.split(OPTION_NAME_SEPERATOR) ?? []; + +export function getSliceTreeNodesFromPath( + sliceTree: SliceTree, + path: string[], +) { + let sliceTreeNode: SliceTreeNode | undefined = undefined; + const nodes: SliceTreeNode[] = []; + + for (const key of path) { + const currentSliceTree: SliceTree = + sliceTreeNode == null ? sliceTree : sliceTreeNode.children; + + sliceTreeNode = checkNotNull(currentSliceTree.get(key)); + nodes.push(sliceTreeNode); + } + + return { sliceTreeNode: checkNotNull(sliceTreeNode), nodes }; +} + +export const getArrayFromMapValues = <_, V>(map: Map<_, V>): V[] => + Array(...map.values()); diff --git a/frontend/src/metabase/visualizations/echarts/tooltip/index.tsx b/frontend/src/metabase/visualizations/echarts/tooltip/index.tsx index 0e4d6d428d11f0a289cf6e5ebb4fcdfe008acf6c..4c79599197cdf67105b3c01b892ed9dfa1854054 100644 --- a/frontend/src/metabase/visualizations/echarts/tooltip/index.tsx +++ b/frontend/src/metabase/visualizations/echarts/tooltip/index.tsx @@ -9,7 +9,8 @@ import type { ComputedVisualizationSettings } from "metabase/visualizations/type import type { ClickObject } from "metabase-lib"; import type { BaseCartesianChartModel } from "../cartesian/model/types"; -import type { PieChartModel } from "../pie/model/types"; +import type { PieChartModel, SliceTreeNode } from "../pie/model/types"; +import { getArrayFromMapValues } from "../pie/util"; export const TOOLTIP_POINTER_MARGIN = 10; @@ -149,10 +150,18 @@ export const useCartesianChartSeriesColorsClasses = ( return useInjectSeriesColorsClasses(hexColors); }; +function getColorsFromSlices(slices: SliceTreeNode[]) { + const colors = slices.map(s => s.color); + slices.forEach(s => + colors.push(...getColorsFromSlices(getArrayFromMapValues(s.children))), + ); + return colors; +} + export const usePieChartValuesColorsClasses = (chartModel: PieChartModel) => { const hexColors = useMemo(() => { - return chartModel.slices.map(slice => slice.data.color); - }, [chartModel.slices]); + return getColorsFromSlices(getArrayFromMapValues(chartModel.sliceTree)); + }, [chartModel]); return useInjectSeriesColorsClasses(hexColors); }; diff --git a/frontend/src/metabase/visualizations/shared/settings/pie.ts b/frontend/src/metabase/visualizations/shared/settings/pie.ts index 1e7dcb36286103f9781dfd34905a8b6c3f5af036..cbd47188cc7f996b826bbc25856670a1991e0294 100644 --- a/frontend/src/metabase/visualizations/shared/settings/pie.ts +++ b/frontend/src/metabase/visualizations/shared/settings/pie.ts @@ -9,6 +9,7 @@ import { getPieColumns } from "metabase/visualizations/echarts/pie/model"; import type { PieRow } from "metabase/visualizations/echarts/pie/model/types"; import type { ShowWarning } from "metabase/visualizations/echarts/types"; import { getNumberOr } from "metabase/visualizations/lib/settings/row-values"; +import { getDefaultDimensionsAndMetrics } from "metabase/visualizations/lib/utils"; import { unaggregatedDataWarningPie } from "metabase/visualizations/lib/warnings"; import type { ComputedVisualizationSettings, @@ -21,10 +22,41 @@ import type { RowValues, } from "metabase-types/api"; +export function getPieDimensions(settings: ComputedVisualizationSettings) { + const dimensionSetting = settings["pie.dimension"]; + + if (dimensionSetting == null) { + throw new Error("`pie.dimension` is undefined"); + } + if (Array.isArray(dimensionSetting)) { + return dimensionSetting; + } + return [dimensionSetting]; +} + +export function getDefaultPieColumns(rawSeries: RawSeries) { + const { dimensions, metrics } = getDefaultDimensionsAndMetrics( + rawSeries, + 3, + 1, + ); + return { + dimension: dimensions, + metric: metrics[0], + }; +} + export const getDefaultShowLegend = () => true; export const getDefaultShowTotal = () => true; +export function getDefaultShowLabels(settings: ComputedVisualizationSettings) { + if (getPieDimensions(settings).length <= 1) { + return false; + } + return true; +} + export const getDefaultPercentVisibility = () => "legend"; export const getDefaultSliceThreshold = () => SLICE_THRESHOLD * 100; @@ -117,10 +149,9 @@ export function getColors( data: { rows, cols }, }, ] = rawSeries; + const dimensionName = getPieDimensions(currentSettings)[0]; - const dimensionIndex = cols.findIndex( - col => col.name === currentSettings["pie.dimension"], - ); + const dimensionIndex = cols.findIndex(col => col.name === dimensionName); const metricIndex = cols.findIndex( col => col.name === currentSettings["pie.metric"], ); diff --git a/frontend/src/metabase/visualizations/types/hover.ts b/frontend/src/metabase/visualizations/types/hover.ts index f59ffa6ed7d8a70cb3ea49a801f3b764e4032a2d..9d21261acb73d972d47150bb6aadf721957a4d33 100644 --- a/frontend/src/metabase/visualizations/types/hover.ts +++ b/frontend/src/metabase/visualizations/types/hover.ts @@ -51,4 +51,6 @@ export interface HoveredObject { event?: MouseEvent; stackedTooltipModel?: StackedTooltipModel; isAlreadyScaled?: boolean; + pieSliceKeyPath?: string[]; + pieLegendHoverIndex?: number; } diff --git a/frontend/src/metabase/visualizations/visualizations/PieChart/DimensionsWidget.modules.css b/frontend/src/metabase/visualizations/visualizations/PieChart/DimensionsWidget.modules.css new file mode 100644 index 0000000000000000000000000000000000000000..27213c540e5ebda87dcea3447a0c67d186b904a7 --- /dev/null +++ b/frontend/src/metabase/visualizations/visualizations/PieChart/DimensionsWidget.modules.css @@ -0,0 +1,3 @@ +.dimensionPicker { + margin-bottom: 10px; +} diff --git a/frontend/src/metabase/visualizations/visualizations/PieChart/DimensionsWidget.tsx b/frontend/src/metabase/visualizations/visualizations/PieChart/DimensionsWidget.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7e12384e89387cc89e7c0e18d73ef32663fded97 --- /dev/null +++ b/frontend/src/metabase/visualizations/visualizations/PieChart/DimensionsWidget.tsx @@ -0,0 +1,219 @@ +import { + DndContext, + type DragEndEvent, + DragOverlay, + type DragStartEvent, + PointerSensor, + useSensor, +} from "@dnd-kit/core"; +import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; +import { + SortableContext, + arrayMove, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { useState } from "react"; +import { t } from "ttag"; + +import { Sortable } from "metabase/core/components/Sortable"; +import { Button, Text } from "metabase/ui"; +import ChartSettingFieldPicker from "metabase/visualizations/components/settings/ChartSettingFieldPicker"; +import { getOptionFromColumn } from "metabase/visualizations/lib/settings/utils"; +import { getPieDimensions } from "metabase/visualizations/shared/settings/pie"; +import type { ComputedVisualizationSettings } from "metabase/visualizations/types"; +import { isDimension } from "metabase-lib/v1/types/utils/isa"; +import type { RawSeries } from "metabase-types/api"; + +import Styles from "./DimensionsWidget.modules.css"; +import { PieRowsPicker } from "./PieRowsPicker"; + +function DimensionPicker({ + value, + options, + showDragHandle, + onChange, + onRemove, +}: { + value: string | undefined; + options: { name: string; value: string }[]; + showDragHandle: boolean; + onChange?: (value: string) => void; + onRemove?: (() => void) | undefined; +}) { + return ( + <ChartSettingFieldPicker + value={value} + options={options} + columnHasSettings={() => false} + onChange={onChange} + onRemove={onRemove} + showColorPicker={false} + showColumnSetting={false} + className={Styles.dimensionPicker} + colors={undefined} + series={undefined} + columns={undefined} + onShowWidget={() => {}} + onChangeSeriesColor={() => {}} + showDragHandle={showDragHandle} + /> + ); +} + +const INNER_RING_TITLE = t`Inner Ring`; +const MIDDLE_RING_TITLE = t`Middle Ring`; +const OUTER_RING_TITLE = t`Outer Ring`; + +const TWO_RING_SETTING_TITLES = [INNER_RING_TITLE, OUTER_RING_TITLE]; +const THREE_RING_SETTING_TITLES = [ + INNER_RING_TITLE, + MIDDLE_RING_TITLE, + OUTER_RING_TITLE, +]; + +export function DimensionsWidget({ + rawSeries, + settings, + onChangeSettings, + onShowWidget, +}: { + rawSeries: RawSeries; + settings: ComputedVisualizationSettings; + onChangeSettings: (newSettings: ComputedVisualizationSettings) => void; + onShowWidget: (widget: any, ref: any) => void; +}) { + // Dimension settings + const [dimensions, setDimensions] = useState<(string | undefined)[]>(() => [ + ...getPieDimensions(settings), + ]); + + const dimensionTitles = + dimensions.length < 3 ? TWO_RING_SETTING_TITLES : THREE_RING_SETTING_TITLES; + + const updateDimensions = (newDimensions: (string | undefined)[]) => { + setDimensions(newDimensions); + onChangeSettings({ + "pie.dimension": newDimensions.filter(d => d != null) as string[], + }); + }; + + const onChangeDimension = (index: number) => (newValue: string) => { + const newDimensions = [...dimensions]; + newDimensions[index] = newValue; + + updateDimensions(newDimensions); + }; + + const onRemove = (index: number) => () => { + const newDimensions = [...dimensions]; + newDimensions.splice(index, 1); + + updateDimensions(newDimensions); + }; + + // Dropdown options + const dimensionOptions = rawSeries[0].data.cols + .filter(isDimension) + .map(getOptionFromColumn); + + const getFilteredOptions = (index: number) => + dimensionOptions.filter( + opt => opt.value === dimensions[index] || !dimensions.includes(opt.value), + ); + + // Drag and drop + const pointerSensor = useSensor(PointerSensor, { + activationConstraint: { distance: 15 }, + }); + const [draggedDimensionIndex, setDraggedDimensionIndex] = useState<number>(); + + const onDragStart = (event: DragStartEvent) => { + setDraggedDimensionIndex( + dimensions.findIndex(d => d === String(event.active.id)), + ); + }; + + const onDragEnd = (event: DragEndEvent) => { + setDraggedDimensionIndex(undefined); + + const over = event.over; + if (over == null) { + return; + } + const sourceIndex = dimensions.findIndex(d => d === event.active.id); + const destIndex = dimensions.findIndex(d => d === over.id); + + if (sourceIndex === -1 || destIndex === -1) { + return; + } + updateDimensions(arrayMove(dimensions, sourceIndex, destIndex)); + }; + + return ( + <> + <DndContext + onDragStart={onDragStart} + onDragEnd={onDragEnd} + modifiers={[restrictToVerticalAxis]} + sensors={[pointerSensor]} + > + <SortableContext + items={(dimensions.filter(d => d != null) as string[]).map(d => ({ + id: d, + }))} + strategy={verticalListSortingStrategy} + > + {dimensions.map((dimension, index) => ( + <> + <Text weight="bold" mb="sm"> + {dimensionTitles[index]} + </Text> + <Sortable + key={String(dimension)} + id={String(dimension)} + disabled={dimensions.length === 1 || dimension == null} + draggingStyle={{ opacity: 0.5 }} + > + <DimensionPicker + key={dimension} + value={dimension} + onChange={onChangeDimension(index)} + onRemove={dimensions.length > 1 ? onRemove(index) : undefined} + options={getFilteredOptions(index)} + showDragHandle={dimensions.length > 1 && dimension != null} + /> + </Sortable> + {index === 0 && ( + <PieRowsPicker + rawSeries={rawSeries} + settings={settings} + onChangeSettings={onChangeSettings} + onShowWidget={onShowWidget} + numRings={dimensions.filter(d => d != null).length} + /> + )} + </> + ))} + <DragOverlay> + {draggedDimensionIndex != null ? ( + <DimensionPicker + value={dimensions[draggedDimensionIndex]} + options={getFilteredOptions(draggedDimensionIndex)} + onRemove={() => {}} + showDragHandle + /> + ) : null} + </DragOverlay> + </SortableContext> + </DndContext> + {dimensions.length < 3 && + dimensions[dimensions.length - 1] != null && + getFilteredOptions(dimensions.length).length > 0 && ( + <Button + variant="subtle" + onClick={() => setDimensions([...dimensions, undefined])} + >{t`Add Ring`}</Button> + )} + </> + ); +} diff --git a/frontend/src/metabase/visualizations/visualizations/PieChart/PieChart.tsx b/frontend/src/metabase/visualizations/visualizations/PieChart/PieChart.tsx index 82c614627e03471a9e23ec0687f3b01bba8fcdd2..debfea2bc38a08b04aa266c737064289e435dd93 100644 --- a/frontend/src/metabase/visualizations/visualizations/PieChart/PieChart.tsx +++ b/frontend/src/metabase/visualizations/visualizations/PieChart/PieChart.tsx @@ -3,12 +3,14 @@ import { type MouseEvent, useCallback, useMemo, useRef, useState } from "react"; import { useSet } from "react-use"; import { isNotNull } from "metabase/lib/types"; +import { extractRemappings } from "metabase/visualizations"; import ChartWithLegend from "metabase/visualizations/components/ChartWithLegend"; import { ResponsiveEChartsRenderer } from "metabase/visualizations/components/EChartsRenderer"; import { getPieChartFormatters } from "metabase/visualizations/echarts/pie/format"; import { getPieChartModel } from "metabase/visualizations/echarts/pie/model"; import { getPieChartOption } from "metabase/visualizations/echarts/pie/option"; import { getTooltipOption } from "metabase/visualizations/echarts/pie/tooltip"; +import { getArrayFromMapValues } from "metabase/visualizations/echarts/pie/util"; import { useCloseTooltipOnScroll, usePieChartValuesColorsClasses, @@ -31,6 +33,7 @@ export function PieChart(props: VisualizationProps) { isFullscreen, } = props; const hoveredIndex = props.hovered?.index; + const hoveredSliceKeyPath = props.hovered?.pieSliceKeyPath; const containerRef = useRef<HTMLDivElement>(null); const chartRef = useRef<EChartsType>(); @@ -50,16 +53,26 @@ export function PieChart(props: VisualizationProps) { isDashboard, isFullscreen, }); + const rawSeriesWithRemappings = useMemo( + () => extractRemappings(rawSeries), + [rawSeries], + ); const chartModel = useMemo( () => getPieChartModel( - rawSeries, + rawSeriesWithRemappings, settings, Array.from(hiddenSlices), renderingContext, showWarning, ), - [rawSeries, settings, hiddenSlices, renderingContext, showWarning], + [ + rawSeriesWithRemappings, + settings, + hiddenSlices, + renderingContext, + showWarning, + ], ); const formatters = useMemo( () => getPieChartFormatters(chartModel, settings, renderingContext), @@ -74,6 +87,7 @@ export function PieChart(props: VisualizationProps) { renderingContext, sideLength, hoveredIndex, + hoveredSliceKeyPath, ), tooltip: getTooltipOption(chartModel, formatters, containerRef), }), @@ -84,6 +98,7 @@ export function PieChart(props: VisualizationProps) { renderingContext, sideLength, hoveredIndex, + hoveredSliceKeyPath, ], ); @@ -100,13 +115,14 @@ export function PieChart(props: VisualizationProps) { const eventHandlers = useChartEvents(props, chartRef, chartModel); - const legendTitles = chartModel.slices - .filter(s => s.data.includeInLegend) + const slices = getArrayFromMapValues(chartModel.sliceTree); + const legendTitles = slices + .filter(s => s.includeInLegend) .map(s => { - const label = s.data.name; + const label = s.name; // Hidden slices don't have a percentage - const sliceHidden = s.data.normalizedPercentage === 0; + const sliceHidden = s.normalizedPercentage === 0; const percentDisabled = settings["pie.percent_visibility"] !== "legend" && settings["pie.percent_visibility"] !== "both"; @@ -117,18 +133,16 @@ export function PieChart(props: VisualizationProps) { return [ label, - formatters.formatPercent(s.data.normalizedPercentage, "legend"), + formatters.formatPercent(s.normalizedPercentage, "legend"), ]; }); - const hiddenSlicesLegendIndices = chartModel.slices - .filter(s => s.data.includeInLegend) - .map((s, index) => (hiddenSlices.has(s.data.key) ? index : null)) + const hiddenSlicesLegendIndices = slices + .filter(s => s.includeInLegend) + .map((s, index) => (hiddenSlices.has(s.key) ? index : null)) .filter(isNotNull); - const legendColors = chartModel.slices - .filter(s => s.data.includeInLegend) - .map(s => s.data.color); + const legendColors = slices.filter(s => s.includeInLegend).map(s => s.color); const showLegend = settings["pie.show_legend"]; @@ -136,6 +150,7 @@ export function PieChart(props: VisualizationProps) { props.onHoverChange( hoverData && { ...hoverData, + pieLegendHoverIndex: hoverData.index, }, ); @@ -143,12 +158,11 @@ export function PieChart(props: VisualizationProps) { event: MouseEvent, sliceIndex: number, ) => { - const slice = chartModel.slices[sliceIndex]; - const willShowSlice = hiddenSlices.has(slice.data.key); - const hasMoreVisibleSlices = - chartModel.slices.length - hiddenSlices.size > 1; + const slice = slices[sliceIndex]; + const willShowSlice = hiddenSlices.has(slice.key); + const hasMoreVisibleSlices = slices.length - hiddenSlices.size > 1; if (hasMoreVisibleSlices || willShowSlice) { - toggleSliceVisibility(slice.data.key); + toggleSliceVisibility(slice.key); } }; diff --git a/frontend/src/metabase/visualizations/visualizations/PieChart/PieRowsPicker.tsx b/frontend/src/metabase/visualizations/visualizations/PieChart/PieRowsPicker.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1e939f0070fbd5f620069e9f14373b8ae7c1b207 --- /dev/null +++ b/frontend/src/metabase/visualizations/visualizations/PieChart/PieRowsPicker.tsx @@ -0,0 +1,62 @@ +import { color } from "metabase/lib/colors"; +import { + ChartSettingSeriesOrder, + type SortableItem, +} from "metabase/visualizations/components/settings/ChartSettingSeriesOrder"; +import type { PieRow } from "metabase/visualizations/echarts/pie/model/types"; +import { getColorForPicker } from "metabase/visualizations/echarts/pie/util/colors"; +import type { ComputedVisualizationSettings } from "metabase/visualizations/types"; +import type { RawSeries } from "metabase-types/api"; + +export function PieRowsPicker({ + rawSeries, + settings, + numRings, + onChangeSettings, + onShowWidget, +}: { + rawSeries: RawSeries; + settings: ComputedVisualizationSettings; + numRings: number; + onChangeSettings: (newSettings: ComputedVisualizationSettings) => void; + onShowWidget: (widget: any, ref: any) => void; +}) { + const pieRows = settings["pie.rows"]; + if (pieRows == null) { + return null; + } + + const onChangeSeriesColor = (sliceKey: string, color: string) => + onChangeSettings({ + "pie.rows": pieRows.map(row => { + if (row.key !== sliceKey) { + return row; + } + return { ...row, color, defaultColor: false }; + }), + }); + + const onSortEnd = (newPieRows: SortableItem[]) => + onChangeSettings({ + "pie.sort_rows": false, + "pie.rows": newPieRows as PieRow[], + }); + + return ( + <ChartSettingSeriesOrder + value={pieRows} + series={rawSeries} + onChangeSeriesColor={onChangeSeriesColor} + onSortEnd={onSortEnd} + onChange={rows => onChangeSettings({ "pie.rows": rows as PieRow[] })} + onShowWidget={onShowWidget} + hasEditSettings + accentColorOptions={ + numRings > 1 + ? { dark: true, main: false, light: false, harmony: false } + : undefined + } + getItemColor={item => getColorForPicker(item.color, numRings > 1, color)} + /> + ); +} diff --git a/frontend/src/metabase/visualizations/visualizations/PieChart/chart-definition.ts b/frontend/src/metabase/visualizations/visualizations/PieChart/chart-definition.ts index 4af837092b225baa546f6b8ed81ecc6907e2ed9e..f25f3aa53939db1858968d8415e811103037913f 100644 --- a/frontend/src/metabase/visualizations/visualizations/PieChart/chart-definition.ts +++ b/frontend/src/metabase/visualizations/visualizations/PieChart/chart-definition.ts @@ -2,8 +2,6 @@ import { t } from "ttag"; import _ from "underscore"; import { formatValue } from "metabase/lib/formatting"; -import { ChartSettingSeriesOrder } from "metabase/visualizations/components/settings/ChartSettingSeriesOrder"; -import type { PieRow } from "metabase/visualizations/echarts/pie/model/types"; import { ChartSettingsError, MinRowsError, @@ -16,14 +14,16 @@ import { } from "metabase/visualizations/lib/settings/utils"; import { getDefaultPercentVisibility, + getDefaultPieColumns, + getDefaultShowLabels, getDefaultShowLegend, + getDefaultShowTotal, getDefaultSliceThreshold, getDefaultSortRows, getPieRows, getPieSortRowsDimensionSetting, } from "metabase/visualizations/shared/settings/pie"; import { SERIES_SETTING_KEY } from "metabase/visualizations/shared/settings/series"; -import { getDefaultShowTotal } from "metabase/visualizations/shared/settings/waterfall"; import { getDefaultSize, getMinSize, @@ -33,8 +33,10 @@ import type { VisualizationDefinition, VisualizationSettingsDefinitions, } from "metabase/visualizations/types"; -import type { RawSeries } from "metabase-types/api"; +import { isDimension, isMetric } from "metabase-lib/v1/types/utils/isa"; +import type { RawSeries, Series } from "metabase-types/api"; +import { DimensionsWidget } from "./DimensionsWidget"; import { SliceNameWidget } from "./SliceNameWidget"; export const PIE_CHART_DEFINITION: VisualizationDefinition = { @@ -43,7 +45,17 @@ export const PIE_CHART_DEFINITION: VisualizationDefinition = { iconName: "pie", minSize: getMinSize("pie"), defaultSize: getDefaultSize("pie"), - isSensible: ({ cols }) => cols.length === 2, + isSensible: ({ cols, rows }) => { + const numDimensions = cols.filter(isDimension).length; + const numMetrics = cols.filter(isMetric).length; + + return ( + rows.length >= 2 && + cols.length >= 2 && + numDimensions >= 1 && + numMetrics >= 1 + ); + }, checkRenderable: ( [ { @@ -85,55 +97,27 @@ export const PIE_CHART_DEFINITION: VisualizationDefinition = { }, ] as RawSeries, settings: { + ...metricSetting("pie.metric", { + section: t`Data`, + title: t`Measure`, + showColumnSetting: true, + getDefault: (rawSeries: Series) => getDefaultPieColumns(rawSeries).metric, + }), ...columnSettings({ hidden: true }), ...dimensionSetting("pie.dimension", { - section: t`Data`, + hidden: true, title: t`Dimension`, showColumnSetting: true, + getDefault: (rawSeries: Series) => + getDefaultPieColumns(rawSeries).dimension, }), "pie.rows": { - section: t`Data`, - widget: ChartSettingSeriesOrder, - getHidden: (_rawSeries, settings) => settings["pie.dimension"] == null, + hidden: true, getValue: (rawSeries, settings) => { return getPieRows(rawSeries, settings, (value, options) => String(formatValue(value, options)), ); }, - getProps: ( - _rawSeries, - vizSettings: ComputedVisualizationSettings, - onChange, - _extra, - onChangeSettings, - ) => { - return { - addButtonLabel: t`Add another row`, - searchPickerPlaceholder: t`Select a row`, - onChangeSeriesColor: (sliceKey: string, color: string) => { - const pieRows = vizSettings["pie.rows"]; - if (pieRows == null) { - throw Error("Missing `pie.rows` setting"); - } - - onChange( - pieRows.map(row => { - if (row.key !== sliceKey) { - return row; - } - return { ...row, color, defaultColor: false }; - }), - ); - }, - onSortEnd: (newPieRows: PieRow[]) => - onChangeSettings({ - "pie.sort_rows": false, - "pie.rows": newPieRows, - "pie.sort_rows_dimension": - getPieSortRowsDimensionSetting(vizSettings), - }), - }; - }, readDependencies: [ "pie.dimension", "pie.metric", @@ -194,11 +178,23 @@ export const PIE_CHART_DEFINITION: VisualizationDefinition = { }, readDependencies: ["pie.rows"], } as any), // any type cast needed to avoid type error from confusion with destructured object params in `nestedSettings` - ...metricSetting("pie.metric", { + + "pie._dimensions_widget": { section: t`Data`, - title: t`Measure`, - showColumnSetting: true, - }), + widget: DimensionsWidget, + getProps: ( + rawSeries: RawSeries, + settings: ComputedVisualizationSettings, + _onChange: any, + _extra: any, + onChangeSettings: (newSettings: ComputedVisualizationSettings) => void, + ) => ({ + rawSeries, + settings, + onChangeSettings, + }), + readDependencies: ["pie.dimension", "pie.rows"], + }, "pie.show_legend": { section: t`Display`, title: t`Show legend`, @@ -213,6 +209,14 @@ export const PIE_CHART_DEFINITION: VisualizationDefinition = { widget: "toggle", getDefault: getDefaultShowTotal, inline: true, + marginBottom: "1rem", + }, + "pie.show_labels": { + section: t`Display`, + title: t`Show labels`, + widget: "toggle", + getDefault: (_rawSeries, settings) => getDefaultShowLabels(settings), + inline: true, }, "pie.percent_visibility": { section: t`Display`, diff --git a/frontend/src/metabase/visualizations/visualizations/PieChart/use-chart-events.ts b/frontend/src/metabase/visualizations/visualizations/PieChart/use-chart-events.ts index 0a223a23e68add6c8f87a4502306b77853b8850a..7b53ab6dc090cc52275e62249cc7cb1d59367af3 100644 --- a/frontend/src/metabase/visualizations/visualizations/PieChart/use-chart-events.ts +++ b/frontend/src/metabase/visualizations/visualizations/PieChart/use-chart-events.ts @@ -3,6 +3,7 @@ import { type MutableRefObject, useEffect, useMemo } from "react"; import { t } from "ttag"; import _ from "underscore"; +import { checkNotNull } from "metabase/lib/types"; import { formatPercent } from "metabase/static-viz/lib/numbers"; import type { EChartsTooltipModel, @@ -14,11 +15,16 @@ import { } from "metabase/visualizations/components/ChartTooltip/StackedDataTooltip/utils"; import type { PieChartFormatters } from "metabase/visualizations/echarts/pie/format"; import type { PieChartModel } from "metabase/visualizations/echarts/pie/model/types"; +import type { EChartsSunburstSeriesMouseEvent } from "metabase/visualizations/echarts/pie/types"; +import { + getArrayFromMapValues, + getSliceKeyPath, + getSliceTreeNodesFromPath, +} from "metabase/visualizations/echarts/pie/util"; import { getMarkerColorClass, useClickedStateTooltipSync, } from "metabase/visualizations/echarts/tooltip"; -import type { EChartsSeriesMouseEvent } from "metabase/visualizations/echarts/types"; import { getFriendlyName } from "metabase/visualizations/lib/utils"; import type { ClickObject, @@ -27,35 +33,39 @@ import type { import type { EChartsEventHandler } from "metabase/visualizations/types/echarts"; export const getTooltipModel = ( - dataIndex: number, + sliceKeyPath: string[], chartModel: PieChartModel, formatters: PieChartFormatters, ): EChartsTooltipModel => { - const hoveredIndex = dataIndexToHoveredIndex(dataIndex, chartModel); - const hoveredOther = - chartModel.slices[hoveredIndex].data.isOther && - chartModel.otherSlices.length > 1; - - const slices = hoveredOther - ? chartModel.otherSlices - : chartModel.slices.filter(slice => slice.data.visible); - - const rows = slices.map(slice => ({ - name: slice.data.name, - value: slice.data.displayValue, - color: hoveredOther ? undefined : slice.data.color, - formatter: formatters.formatMetric, - })); + const { sliceTreeNode, nodes } = getSliceTreeNodesFromPath( + chartModel.sliceTree, + sliceKeyPath, + ); + const siblingNodes = getArrayFromMapValues( + nodes.length >= 2 ? nodes[nodes.length - 2].children : chartModel.sliceTree, + ); + const rows = ( + sliceTreeNode.isOther + ? getArrayFromMapValues(sliceTreeNode.children) + : siblingNodes + ) + .filter(node => node.visible) + .map(slice => ({ + name: slice.name, + value: slice.displayValue, + color: nodes.length === 1 ? slice.color : undefined, + formatter: formatters.formatMetric, + key: slice.key, + })); const rowsTotal = getTotalValue(rows); - const isShowingTotalSensible = rows.length > 1; - const formattedRows: EChartsTooltipRow[] = rows.map((row, index) => { + const formattedRows: EChartsTooltipRow[] = rows.map(row => { const markerColorClass = row.color ? getMarkerColorClass(row.color) : undefined; return { - isFocused: !hoveredOther && index === dataIndex - 1, + isFocused: !sliceTreeNode.isOther && row.key === sliceTreeNode.key, markerColorClass, name: row.name, values: [ @@ -66,92 +76,97 @@ export const getTooltipModel = ( }); return { - header: getFriendlyName(chartModel.colDescs.dimensionDesc.column), + header: + nodes.length === 1 + ? getFriendlyName(sliceTreeNode.column) + : nodes + .slice(0, -1) + .map(node => node.name) + .join(" > "), rows: formattedRows, - footer: isShowingTotalSensible - ? { - name: t`Total`, - values: [ - formatters.formatMetric(rowsTotal), - formatPercent(getPercent(chartModel.total, rowsTotal) ?? 0), - ], - } - : undefined, + footer: + rows.length > 1 + ? { + name: t`Total`, + values: [ + formatters.formatMetric(rowsTotal), + formatPercent(getPercent(chartModel.total, rowsTotal) ?? 0), + ], + } + : undefined, }; }; -const dataIndexToHoveredIndex = (index: number, chartModel: PieChartModel) => { - const visibleSlices = chartModel.slices.filter(slice => slice.data.visible); - const slice = visibleSlices[index - 1]; - const innerIndex = chartModel.slices.findIndex( - s => s.data.key === slice.data.key && s.data.isOther === slice.data.isOther, - ); - return innerIndex; -}; - -const hoveredIndexToDataIndex = (index: number, chartModel: PieChartModel) => { - const baseIndex = index + 1; - const slicesBefore = chartModel.slices.slice(0, index); - const hiddenSlicesBefore = slicesBefore.filter(slice => !slice.data.visible); - return baseIndex - hiddenSlicesBefore.length; -}; - function getHoverData( - event: EChartsSeriesMouseEvent, + event: EChartsSunburstSeriesMouseEvent, chartModel: PieChartModel, ) { if (event.dataIndex == null) { return null; } - const index = dataIndexToHoveredIndex(event.dataIndex, chartModel); - const indexOutOfBounds = chartModel.slices[index] == null; - if (indexOutOfBounds || chartModel.slices[index].data.noHover) { - return null; + const pieSliceKeyPath = getSliceKeyPath(event); + + const dimensionNode = chartModel.sliceTree.get(pieSliceKeyPath[0]); + if (dimensionNode == null) { + throw Error(`Could not find dimensionNode for key ${pieSliceKeyPath[0]}`); } return { - index, + index: dimensionNode.legendHoverIndex, event: event.event.event, + pieSliceKeyPath, }; } function handleClick( - event: EChartsSeriesMouseEvent, + event: EChartsSunburstSeriesMouseEvent, dataProp: VisualizationProps["data"], settings: VisualizationProps["settings"], visualizationIsClickable: VisualizationProps["visualizationIsClickable"], onVisualizationClick: VisualizationProps["onVisualizationClick"], chartModel: PieChartModel, ) { - if (!event.dataIndex) { + if (event.dataIndex == null) { + return; + } + + const { sliceTreeNode, nodes } = getSliceTreeNodesFromPath( + chartModel.sliceTree, + getSliceKeyPath(event), + ); + + if (sliceTreeNode.isOther) { return; } - const index = dataIndexToHoveredIndex(event.dataIndex, chartModel); - const slice = chartModel.slices[index]; + + const rowIndex = sliceTreeNode.rowIndex; + const data = - slice.data.rowIndex != null - ? dataProp.rows[slice.data.rowIndex].map((value, index) => ({ + rowIndex != null + ? dataProp.rows[rowIndex].map((value, index) => ({ value, col: dataProp.cols[index], })) : undefined; + if (data != null) { + data[chartModel.colDescs.metricDesc.index].value = sliceTreeNode.value; + } + const clickObject: ClickObject = { - value: slice.data.value, + value: sliceTreeNode.value, column: chartModel.colDescs.metricDesc.column, data, - dimensions: [ - { - value: slice.data.key, - column: chartModel.colDescs.dimensionDesc.column, - }, - ], + dimensions: nodes.map(node => ({ + value: node.key, + column: checkNotNull(node.column), + })), settings, event: event.event.event, }; - if (visualizationIsClickable(clickObject) && !slice.data.isOther) { + if (visualizationIsClickable(clickObject)) { onVisualizationClick(clickObject); } } @@ -168,30 +183,37 @@ export function useChartEvents( visualizationIsClickable, onVisualizationClick, } = props; - const hoveredIndex = props.hovered?.index; + // We use `pieLegendHoverIndex` instead of `hovered.index` because we only + // want to manually highlight and downplay when the user hovers over the + // legend. If the user hovers over the chart, echarts will handle highlighting + // the chart itself. + const legendHoverIndex = props.hovered?.pieLegendHoverIndex; const chart = chartRef?.current; useEffect( function higlightChartOnLegendHover() { - if (chart == null || hoveredIndex == null) { + if (chart == null || legendHoverIndex == null) { return; } + const name = getArrayFromMapValues(chartModel.sliceTree)[legendHoverIndex] + .key; + chart.dispatchAction({ type: "highlight", - dataIndex: hoveredIndexToDataIndex(hoveredIndex, chartModel), + name, seriesIndex: 0, }); return () => { chart.dispatchAction({ type: "downplay", - dataIndex: hoveredIndexToDataIndex(hoveredIndex, chartModel), + name, seriesIndex: 0, }); }; }, - [chart, chartModel, hoveredIndex], + [chart, chartModel, legendHoverIndex], ); useClickedStateTooltipSync(chartRef.current, props.clicked); @@ -208,14 +230,14 @@ export function useChartEvents( { eventName: "mousemove", query: "series", - handler: (event: EChartsSeriesMouseEvent) => { + handler: (event: EChartsSunburstSeriesMouseEvent) => { onHoverChange?.(getHoverData(event, chartModel)); }, }, { eventName: "click", query: "series", - handler: (event: EChartsSeriesMouseEvent) => { + handler: (event: EChartsSunburstSeriesMouseEvent) => { handleClick( event, data,