Compare commits

...

9 Commits

Author SHA1 Message Date
Emmad Usmani
5815b79079 rename branch in uberjar.yml 2024-06-05 09:57:53 -07:00
Adam James
c9fa3fbfe1 One last spot where we don't need old pie stuff anymore. 2024-06-04 12:05:39 -07:00
Adam James
602a1dda3e Merge branch 'echarts_pie' of https://github.com/metabase/metabase into echarts_pie 2024-06-04 12:03:30 -07:00
Emmad Usmani
001ff3700d echarts pie 2024-06-04 11:51:38 -07:00
Adam James
a02ecf943b Get rid of this test that was attempting to use old code path 2024-06-04 11:48:09 -07:00
Adam James
b2fd8a04df alter static-viz pie chart be tests to account for echarts-impl
The old implementation had a different implementation for legends, separate from the 'categorical/donut' js
render. Echarts nicely combines the legend into the pie render, so we don't need to worry about that stuff anymore.

Instead, I've written a simple set of assertions on the echarts pie implementation now.
2024-06-04 11:38:50 -07:00
Emmad Usmani
3f680f1b0e disable animation 2024-06-04 11:31:51 -07:00
Emmad Usmani
dfbb8d50ab setup rendering pipeline for static pie chart (#43547)
* setup rendering pipeline for static pie chart

* remove more donut code from BE

* compute viz settings for static pie chart (#43548)

* compute viz settings for static pie chart

* create chartModel for pie chart (#43549)

* create chartModel for pie chart

* render static pie chart (#43550)

* render static pie chart

* render static pie chart legend (#43551)
2024-06-04 11:21:01 -07:00
Emmad Usmani
afb21d0e3d echarts pie 2024-06-04 11:21:01 -07:00
36 changed files with 1019 additions and 440 deletions

View File

@@ -4,6 +4,7 @@ on:
push:
branches:
- "master"
- "echarts-pie" # TODO: remove this before merging to master
paths-ignore:
# config files
- ".**"

View File

@@ -210,6 +210,15 @@ export type VisualizationSettings = {
"scalar.switch_positive_negative"?: boolean;
"scalar.compact_primary_number"?: boolean;
// Pie Settings
"pie.dimension": string;
"pie.metric": string;
"pie.show_legend": boolean;
"pie.show_total": boolean;
"pie.percent_visibility": "off" | "legend" | "inside";
"pie.slice_threshold": number;
"pie.colors": Record<string, string>;
[key: string]: any;
};

View File

@@ -6,3 +6,19 @@ export function moveElement<T>(array: T[], oldIndex: number, newIndex: number) {
export const sumArray = (values: number[]) =>
values.reduce((acc, value) => acc + value, 0);
export const findWithIndex = <T>(
arr: T[],
predicate: (value: T, index: number, arr: T[]) => boolean,
) => {
for (let i = 0; i < arr.length; i++) {
const item = arr[i];
if (predicate(item, i, arr)) {
return { item, index: i };
}
}
return {
index: -1,
item: undefined,
};
};

View File

@@ -1,147 +0,0 @@
import { Group } from "@visx/group";
import { Pie } from "@visx/shape";
import { Text } from "@visx/text";
import PropTypes from "prop-types";
import { t } from "ttag";
import { getTextColorForBackground } from "metabase/lib/colors";
import { DIMENSION_ACCESSORS } from "../../constants/accessors";
import { formatNumber, formatPercent } from "../../lib/numbers";
const propTypes = {
data: PropTypes.array,
colors: PropTypes.object,
accessors: PropTypes.shape({
dimension: PropTypes.func,
metric: PropTypes.func,
}),
settings: PropTypes.shape({
metric: PropTypes.object,
percent_visibility: PropTypes.oneOf(["off", "legend", "inside"]),
show_total: PropTypes.bool,
}),
};
const layout = {
width: 540,
height: 540,
margin: 20,
font: {
family: "Lato, sans-serif",
weight: 700,
},
colors: {
textLight: "#b8bbc3",
textDark: "#4c5773",
},
thickness: 100,
cornerRadius: 2,
padAngle: 0.02,
valueFontSize: 22,
labelFontSize: 14,
arcLabelFontSize: 18,
};
const CategoricalDonutChart = ({
data,
colors,
accessors = DIMENSION_ACCESSORS,
settings,
}) => {
const innerWidth = layout.width - layout.margin * 2;
const innerHeight = layout.height - layout.margin * 2;
const outerRadius = Math.min(innerWidth, innerHeight) / 2;
const innerRadius = outerRadius - layout.thickness;
const centerX = layout.margin + innerWidth / 2;
const centerY = layout.margin + innerHeight / 2;
const pieSortValues = (a, b) => b - a;
const textHeight = layout.valueFontSize + layout.labelFontSize;
const textCenter = textHeight / 3;
const totalValue = data.map(accessors.metric).reduce((a, b) => a + b, 0);
const totalLabel = t`Total`.toUpperCase();
const shouldShowLabels = settings?.percent_visibility === "inside";
const shouldShowTotal = settings?.show_total ?? true;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={layout.width}
height={layout.height}
>
<Group top={centerY} left={centerX}>
<Pie
data={data}
pieValue={accessors.metric}
pieSortValues={pieSortValues}
outerRadius={outerRadius}
innerRadius={innerRadius}
cornerRadius={layout.cornerRadius}
padAngle={layout.padAngle}
>
{pie =>
pie.arcs.map((arc, index) => {
const path = pie.path(arc);
const dimension = arc.data[0];
const fill = colors[dimension];
const hasSpaceForLabel = arc.endAngle - arc.startAngle >= 0.32;
const [centroidX, centroidY] = pie.path.centroid(arc);
const percent = arc.value / totalValue;
const labelColor = getTextColorForBackground(fill);
return (
<g key={`arc-${index}`}>
<path d={path} fill={fill} />
{shouldShowLabels && hasSpaceForLabel && (
<text
fontFamily={layout.font.family}
x={centroidX}
y={centroidY}
dy=".33em"
fill={labelColor}
fontSize={layout.arcLabelFontSize}
textAnchor="middle"
>
{formatPercent(percent)}
</text>
)}
</g>
);
})
}
</Pie>
{shouldShowTotal && (
<Group
fontFamily={layout.font.family}
fontWeight={layout.font.weight}
>
<Text
y={-textCenter}
fill={layout.colors.textDark}
fontSize={layout.valueFontSize}
textAnchor="middle"
dominantBaseline="middle"
>
{formatNumber(totalValue, settings?.metric)}
</Text>
<Text
y={textCenter}
fill={layout.colors.textLight}
fontSize={layout.labelFontSize}
textAnchor="middle"
dominantBaseline="middle"
>
{totalLabel}
</Text>
</Group>
)}
</Group>
</svg>
);
};
CategoricalDonutChart.propTypes = propTypes;
export default CategoricalDonutChart;

View File

@@ -1,17 +0,0 @@
import type { ComponentStory } from "@storybook/react";
import { DEFAULT } from "metabase/static-viz/components/CategoricalDonutChart/stories-data";
import CategoricalDonutChart from "./CategoricalDonutChart";
export default {
title: "static-viz/CategoricalDonutChart",
component: CategoricalDonutChart,
};
const Template: ComponentStory<typeof CategoricalDonutChart> = args => {
return <CategoricalDonutChart {...args} />;
};
export const Default = Template.bind({});
Default.args = DEFAULT;

View File

@@ -1,60 +0,0 @@
import { render, screen } from "@testing-library/react";
import CategoricalDonutChart from "./CategoricalDonutChart";
const data = [
["donut", 2000],
["cronut", 3100],
];
const colors = {
donut: "#509EE3",
cronut: "#DDECFA",
};
const settings = {
metric: {
number_style: "currency",
currency: "USD",
currency_style: "symbol",
},
};
describe("CategoricalDonutChart", () => {
it("should apply formatting", () => {
render(
<CategoricalDonutChart data={data} colors={colors} settings={settings} />,
);
expect(screen.getByText("$5,100.00")).toBeInTheDocument();
expect(screen.getAllByText("TOTAL")).toHaveLength(2);
});
it("should render data labels when percent_visibility=inside", () => {
render(
<CategoricalDonutChart
data={data}
colors={colors}
settings={{ percent_visibility: "inside" }}
/>,
);
expect(screen.getByText("39.22 %")).toBeInTheDocument();
expect(screen.getByText("60.78 %")).toBeInTheDocument();
});
it("should render data labels when percent_visibility is `off` or `legend`", () => {
render(
<>
<CategoricalDonutChart
data={data}
colors={colors}
settings={{ percent_visibility: "off" }}
/>
<CategoricalDonutChart
data={data}
colors={colors}
settings={{ percent_visibility: "legend" }}
/>
</>,
);
expect(screen.queryByText("60.78 %")).not.toBeInTheDocument();
});
});

View File

@@ -1 +0,0 @@
export { default } from "./CategoricalDonutChart";

View File

@@ -1,10 +0,0 @@
export const DEFAULT = {
data: [
["donut", 2000],
["cronut", 3100],
],
colors: {
donut: "#509EE3",
cronut: "#DDECFA",
},
};

View File

@@ -1,4 +1,7 @@
import { getCommonStaticVizSettings } from "metabase/static-viz/lib/settings";
import {
fillWithDefaultValue,
getCommonStaticVizSettings,
} from "metabase/static-viz/lib/settings";
import { getCardsColumns } from "metabase/visualizations/echarts/cartesian/model";
import {
getCardsSeriesModels,
@@ -53,17 +56,6 @@ import type {
} from "metabase/visualizations/types";
import type { RawSeries, VisualizationSettings } from "metabase-types/api";
export const fillWithDefaultValue = (
settings: Record<string, unknown>,
key: string,
defaultValue: unknown,
isValid = true,
) => {
if (typeof settings[key] === "undefined" || !isValid) {
settings[key] = defaultValue;
}
};
const getSeriesFunction = (
rawSeries: RawSeries,
settings: Partial<ComputedVisualizationSettings>,

View File

@@ -0,0 +1,37 @@
import type { ComponentStory } from "@storybook/react";
import { color } from "metabase/lib/colors";
import { formatStaticValue } from "metabase/static-viz/lib/format";
import { measureTextWidth } from "metabase/static-viz/lib/text";
import type { RenderingContext } from "metabase/visualizations/types";
import { PieChart } from "./PieChart";
import { data } from "./stories-data";
export default {
title: "static-viz/PieChart",
component: PieChart,
};
const Template: ComponentStory<typeof PieChart> = args => {
return (
<div style={{ border: "1px solid black", display: "inline-block" }}>
<PieChart {...args} isStorybook />
</div>
);
};
const renderingContext: RenderingContext = {
getColor: color,
formatValue: formatStaticValue as any,
measureText: (text, style) =>
measureTextWidth(text, Number(style.size), Number(style.weight)),
fontFamily: "Lato",
};
export const Default = Template.bind({});
Default.args = {
rawSeries: data.defaultSettings as any,
dashcardSettings: {},
renderingContext,
};

View File

@@ -0,0 +1,74 @@
import { Group } from "@visx/group";
import { init } from "echarts";
import { sanitizeSvgForBatik } from "metabase/static-viz/lib/svg";
import { DIMENSIONS } from "metabase/visualizations/echarts/pie/constants";
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 type { StaticChartProps } from "../StaticVisualization";
import { getPieChartLegend } from "./legend";
import { computeStaticPieChartSettings } from "./settings";
export function PieChart({
rawSeries,
dashcardSettings,
renderingContext,
isStorybook,
}: StaticChartProps) {
const computedVizSettings = computeStaticPieChartSettings(
rawSeries,
dashcardSettings,
);
const chartModel = getPieChartModel(
rawSeries,
computedVizSettings,
renderingContext,
);
const formatters = getPieChartFormatters(
chartModel,
computedVizSettings,
renderingContext,
);
const option = getPieChartOption(
chartModel,
formatters,
computedVizSettings,
renderingContext,
);
const { legendHeight, Legend } = getPieChartLegend(
chartModel,
formatters,
computedVizSettings,
DIMENSIONS.sideLen,
DIMENSIONS.paddingTop,
);
const chart = init(null, null, {
renderer: "svg",
ssr: true,
width: DIMENSIONS.sideLen,
height: DIMENSIONS.sideLen,
});
chart.setOption(option);
const chartSvg = sanitizeSvgForBatik(
chart.renderToSVGString(),
isStorybook ?? false,
);
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={DIMENSIONS.sideLen}
height={DIMENSIONS.sideLen + DIMENSIONS.paddingTop + legendHeight}
>
<Legend />
<Group
top={DIMENSIONS.paddingTop + legendHeight}
dangerouslySetInnerHTML={{ __html: chartSvg }}
></Group>
</svg>
);
}

View File

@@ -0,0 +1,56 @@
import type { PieChartFormatters } from "metabase/visualizations/echarts/pie/format";
import type { PieChartModel } from "metabase/visualizations/echarts/pie/model/types";
import type { ComputedVisualizationSettings } from "metabase/visualizations/types";
import { Legend } from "../Legend";
import { calculateLegendRows } from "../Legend/utils";
const FONT = {
lineHeight: 20,
size: 14,
weight: 700,
};
export function getPieChartLegend(
chartModel: PieChartModel,
formatters: PieChartFormatters,
settings: ComputedVisualizationSettings,
width: number,
top: number,
) {
if (!settings["pie.show_legend"] || chartModel.slices.length <= 1) {
return { legendHeight: 0, Legend: () => null };
}
const legendRows = calculateLegendRows({
items: chartModel.slices.map(s => ({
name:
settings["pie.percent_visibility"] === "legend"
? `${s.key} - ${formatters.formatPercent(s.normalizedPercentage)}`
: s.key,
color: s.color,
key: s.key,
})),
width,
lineHeight: FONT.lineHeight,
fontSize: FONT.size,
fontWeight: FONT.weight,
});
if (!legendRows) {
throw Error("Error calculating legend rows");
}
const { height: legendHeight, items } = legendRows;
return {
legendHeight,
Legend: () => (
<Legend
items={items}
top={top}
fontSize={FONT.size}
fontWeight={FONT.weight}
/>
),
};
}

View File

@@ -0,0 +1,44 @@
import {
fillWithDefaultValue,
getCommonStaticVizSettings,
} from "metabase/static-viz/lib/settings";
import { getDefaultDimensionAndMetric } from "metabase/visualizations/lib/utils";
import {
getDefaultColors,
getDefaultPercentVisibility,
getDefaultShowLegend,
getDefaultShowTotal,
getDefaultSliceThreshold,
} from "metabase/visualizations/shared/settings/pie";
import type { ComputedVisualizationSettings } from "metabase/visualizations/types";
import type { RawSeries, VisualizationSettings } from "metabase-types/api";
export function computeStaticPieChartSettings(
rawSeries: RawSeries,
dashcardSettings: VisualizationSettings,
): ComputedVisualizationSettings {
const settings = getCommonStaticVizSettings(rawSeries, dashcardSettings);
const { dimension, metric } = getDefaultDimensionAndMetric(rawSeries);
fillWithDefaultValue(settings, "pie.dimension", dimension);
fillWithDefaultValue(settings, "pie.metric", metric);
fillWithDefaultValue(settings, "pie.show_legend", getDefaultShowLegend());
fillWithDefaultValue(settings, "pie.show_total", getDefaultShowTotal());
fillWithDefaultValue(
settings,
"pie.percent_visibility",
getDefaultPercentVisibility(),
);
fillWithDefaultValue(
settings,
"pie.slice_threshold",
getDefaultSliceThreshold(),
);
fillWithDefaultValue(
settings,
"pie.colors",
getDefaultColors(rawSeries, settings), // TODO fix type error
);
return settings;
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,5 @@
import defaultSettings from "./default-settings.json";
export const data = {
defaultSettings,
};

View File

@@ -3,6 +3,7 @@ import type { StaticVisualizationProps } from "metabase/visualizations/types";
import { ComboChart } from "../ComboChart";
import { FunnelBarChart } from "../FunnelBarChart";
import { PieChart } from "../PieChart/PieChart";
import { ScalarChart } from "../ScalarChart";
import { ScatterPlot } from "../ScatterPlot/ScatterPlot";
import { SmartScalar } from "../SmartScalar";
@@ -31,6 +32,8 @@ export const StaticVisualization = (props: StaticVisualizationProps) => {
return <ScalarChart {...staticVisualizationProps} />;
case "smartscalar":
return <SmartScalar {...staticVisualizationProps} />;
case "pie":
return <PieChart {...staticVisualizationProps} />;
}
throw new Error(`Unsupported display type: ${display}`);

View File

@@ -1,3 +1,4 @@
import { fillWithDefaultValue } from "metabase/static-viz/lib/settings";
import {
getDefaultDecreaseColor,
getDefaultIncreaseColor,
@@ -10,10 +11,7 @@ import type {
} from "metabase/visualizations/types";
import type { RawSeries, VisualizationSettings } from "metabase-types/api";
import {
computeStaticComboChartSettings,
fillWithDefaultValue,
} from "../ComboChart/settings";
import { computeStaticComboChartSettings } from "../ComboChart/settings";
export function computeStaticWaterfallChartSettings(
rawSeries: RawSeries,

View File

@@ -1,5 +1,4 @@
import type { ColorPalette } from "metabase/lib/colors/types";
import CategoricalDonutChart from "metabase/static-viz/components/CategoricalDonutChart";
import Funnel from "metabase/static-viz/components/FunnelChart";
import Gauge from "metabase/static-viz/components/Gauge";
import ProgressBar from "metabase/static-viz/components/ProgressBar";
@@ -7,7 +6,6 @@ import RowChart from "metabase/static-viz/components/RowChart";
import { createColorGetter } from "metabase/static-viz/lib/colors";
export type LegacyStaticChartType =
| "categorical/donut"
| "progress"
| "row"
| "waterfall"
@@ -31,8 +29,6 @@ export const LegacyStaticChart = ({
const chartProps = { ...options, getColor };
switch (type) {
case "categorical/donut":
return <CategoricalDonutChart {...chartProps} />;
case "gauge":
return <Gauge {...chartProps} />;
case "row":

View File

@@ -10,6 +10,17 @@ import type {
VisualizationSettings,
} from "metabase-types/api";
export const fillWithDefaultValue = (
settings: Record<string, unknown>,
key: string,
defaultValue: unknown,
isValid = true,
) => {
if (typeof settings[key] === "undefined" || !isValid) {
settings[key] = defaultValue;
}
};
const getColumnSettings = (
column: DatasetColumn,
settings: VisualizationSettings,

View File

@@ -0,0 +1,22 @@
export const DIMENSIONS = {
paddingTop: 16,
sideLen: 540,
margin: 20,
slice: {
thickness: 100,
borderWidth: 4,
},
// placeholders, real values computed below
innerSideLen: 0,
outerRadius: 0,
innerRadius: 0,
};
DIMENSIONS.innerSideLen = DIMENSIONS.sideLen - DIMENSIONS.margin * 2;
DIMENSIONS.outerRadius = DIMENSIONS.innerSideLen / 2;
DIMENSIONS.innerRadius = DIMENSIONS.outerRadius - DIMENSIONS.slice.thickness;
// TODO use this for settings, dynamic
export const SLICE_THRESHOLD = 0.025; // approx 1 degree in percentage
export const OTHER_SLICE_MIN_PERCENTAGE = 0.003;

View File

@@ -0,0 +1,47 @@
import { computeMaxDecimalsForValues } from "metabase/visualizations/lib/utils";
import type {
ComputedVisualizationSettings,
RenderingContext,
} from "metabase/visualizations/types";
import type { PieChartModel } from "./model/types";
export interface PieChartFormatters {
formatMetric: (value: unknown) => string;
formatPercent: (value: unknown) => string;
}
export function getPieChartFormatters(
chartModel: PieChartModel,
settings: ComputedVisualizationSettings,
renderingContext: RenderingContext,
): PieChartFormatters {
const { column: getColumnSettings } = settings;
if (!getColumnSettings) {
throw Error(`"settings.column" is undefined`);
}
const metricColSettings = getColumnSettings(
chartModel.colDescs.metricDesc.column,
);
const formatMetric = (value: unknown) =>
renderingContext.formatValue(value, {
...metricColSettings,
});
const formatPercent = (value: unknown) =>
renderingContext.formatValue(value, {
column: metricColSettings.column,
number_separators: metricColSettings.number_separators as string,
number_style: "percent",
decimals: computeMaxDecimalsForValues(
chartModel.slices.map(s => s.normalizedPercentage),
{
style: "percent",
maximumSignificantDigits: 2,
},
),
});
return { formatMetric, formatPercent };
}

View File

@@ -0,0 +1,131 @@
import { t } from "ttag";
import _ from "underscore";
import { findWithIndex } from "metabase/lib/arrays";
import { NULL_DISPLAY_VALUE } from "metabase/lib/constants";
import type {
ComputedVisualizationSettings,
RenderingContext,
} from "metabase/visualizations/types";
import type { RawSeries, RowValue } from "metabase-types/api";
import { OTHER_SLICE_MIN_PERCENTAGE } from "../constants";
import type { PieColumnDescriptors, PieChartModel, PieSlice } from "./types";
function getColDescs(
rawSeries: RawSeries,
settings: ComputedVisualizationSettings,
): PieColumnDescriptors {
const [
{
data: { cols },
},
] = rawSeries;
const dimension = findWithIndex(
cols,
c => c.name === settings["pie.dimension"],
);
const metric = findWithIndex(cols, c => c.name === settings["pie.metric"]);
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 {
dimensionDesc: {
index: dimension.index,
column: dimension.item,
},
metricDesc: {
index: metric.index,
column: metric.item,
},
};
}
export function getRowValues(row: RowValue[], colDescs: PieColumnDescriptors) {
const { dimensionDesc, metricDesc } = colDescs;
// dimension val needs to be string to use as key for objects, such as "pie.colors" setting
const dimensionValue = String(row[dimensionDesc.index] ?? NULL_DISPLAY_VALUE);
const metricValue = row[metricDesc.index] ?? 0;
if (typeof metricValue !== "number") {
throw new Error(
`Pie chart metric value (${metricValue}) should be a number`,
);
}
return { dimensionValue, metricValue };
}
export function getPieChartModel(
rawSeries: RawSeries,
settings: ComputedVisualizationSettings,
renderingContext: RenderingContext,
): PieChartModel {
const [
{
data: { rows },
},
] = rawSeries;
const colDescs = getColDescs(rawSeries, settings);
const total = rows.reduce(
(currTotal, row) => currTotal + getRowValues(row, colDescs).metricValue,
0,
);
const [slices, others] = _.chain(rows)
.map((row, index): PieSlice => {
const { dimensionValue, metricValue } = getRowValues(row, colDescs);
if (!settings["pie.colors"]) {
throw Error(`"pie.colors" setting is not defined`);
}
return {
key: dimensionValue,
value: metricValue,
tooltipDisplayValue: metricValue,
normalizedPercentage: metricValue / total, // slice percentage values are normalized to 0-1 scale
rowIndex: index,
color: settings["pie.colors"][dimensionValue],
};
})
.partition(
slice =>
slice.normalizedPercentage >=
(settings["pie.slice_threshold"] ?? 0) / 100, // stored setting for "pie.slice_threshold" is on 0-100 scale to match user input
)
.value();
// 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) {
return { slices, total, colDescs };
}
const otherSlice: PieSlice = {
key: t`Other`,
value: otherTotal,
tooltipDisplayValue: otherTotal,
normalizedPercentage: otherTotal / total,
color: renderingContext.getColor("text-light"),
};
// Increase "other" slice so it's barely visible
if (otherSlice.normalizedPercentage < OTHER_SLICE_MIN_PERCENTAGE) {
otherSlice.value = total * OTHER_SLICE_MIN_PERCENTAGE;
}
return {
slices: [...slices, otherSlice],
total,
colDescs,
};
}

View File

@@ -0,0 +1,21 @@
import type { ColumnDescriptor } from "metabase/visualizations/lib/graph/columns";
export interface PieColumnDescriptors {
metricDesc: ColumnDescriptor;
dimensionDesc: ColumnDescriptor;
}
export interface PieSlice {
key: string;
value: number; // size of the slice used for rendering
tooltipDisplayValue: number; // real value of the slice displayed in tooltip
normalizedPercentage: number;
rowIndex?: number;
color: string;
}
export interface PieChartModel {
slices: PieSlice[];
total: number;
colDescs: PieColumnDescriptors;
}

View File

@@ -0,0 +1,50 @@
import type { RegisteredSeriesOption } from "echarts";
import { t } from "ttag";
import { DIMENSIONS } from "../constants";
export const SUNBURST_SERIES_OPTION: RegisteredSeriesOption["sunburst"] = {
type: "sunburst",
sort: undefined,
radius: [DIMENSIONS.innerRadius, DIMENSIONS.outerRadius],
label: {
rotate: 0,
overflow: "none",
fontSize: 20,
fontWeight: 700,
},
itemStyle: {
borderWidth: DIMENSIONS.slice.borderWidth,
},
};
export const TOTAL_GRAPHIC_OPTION = {
type: "group",
top: "center",
left: "center",
children: [
{
type: "text",
cursor: "text",
style: {
fontSize: "22px",
fontWeight: "700",
textAlign: "center",
// placeholder values to keep typescript happy
fontFamily: "",
fill: "",
},
},
{
type: "text",
cursor: "text",
top: 26,
style: {
fontSize: "14px",
fontWeight: "700",
textAlign: "center",
text: t`Total`.toUpperCase(),
},
},
],
};

View File

@@ -0,0 +1,97 @@
import type { EChartsOption } from "echarts";
import cloneDeep from "lodash.clonedeep";
import { getTextColorForBackground } from "metabase/lib/colors";
import type {
ComputedVisualizationSettings,
Formatter,
RenderingContext,
} from "metabase/visualizations/types";
import type { PieChartFormatters } from "../format";
import type { PieChartModel, PieSlice } from "../model/types";
import { SUNBURST_SERIES_OPTION, TOTAL_GRAPHIC_OPTION } from "./constants";
function getSliceByKey(key: string, slices: PieSlice[]) {
const slice = slices.find(s => s.key === key);
if (!slice) {
throw Error(
`Could not find slice with key ${key} in slices: ${JSON.stringify(
slices,
)}`,
);
}
return slice;
}
function getTotalGraphicOption(
total: number,
formatMetric: Formatter,
renderingContext: RenderingContext,
) {
const graphicOption = cloneDeep(TOTAL_GRAPHIC_OPTION);
graphicOption.children.forEach(child => {
child.style.fontFamily = renderingContext.fontFamily;
});
graphicOption.children[0].style.text = formatMetric(Math.round(total));
graphicOption.children[0].style.fill = renderingContext.getColor("text-dark");
graphicOption.children[1].style.fill =
renderingContext.getColor("text-light");
return graphicOption;
}
export function getPieChartOption(
chartModel: PieChartModel,
formatters: PieChartFormatters,
settings: ComputedVisualizationSettings,
renderingContext: RenderingContext,
): EChartsOption {
// "Show total" setting
const graphicOption = settings["pie.show_total"]
? getTotalGraphicOption(
chartModel.total,
formatters.formatMetric,
renderingContext,
)
: undefined;
// "Show percentages: On the chart" setting
const seriesOption = cloneDeep(SUNBURST_SERIES_OPTION); // deep clone to avoid sharing label.formatter with other instances
if (!seriesOption.label) {
throw Error(`"seriesOption.label" is undefined`);
}
seriesOption.label.formatter = ({ name }) => {
if (settings["pie.percent_visibility"] !== "inside") {
return " ";
}
return formatters.formatPercent(
getSliceByKey(name, chartModel.slices).normalizedPercentage,
);
};
return {
animation: false, // TODO when implementing the dynamic pie chart, use animations for opacity transitions, but disable initial animation
textStyle: {
fontFamily: renderingContext.fontFamily,
},
graphic: graphicOption,
series: {
...seriesOption,
data: chartModel.slices.map(s => ({
value: s.value,
name: s.key,
itemStyle: { color: s.color },
label: {
color: getTextColorForBackground(s.color, renderingContext.getColor),
},
})),
},
};
}

View File

@@ -0,0 +1,30 @@
import { getColorsForValues } from "metabase/lib/colors/charts";
import { SLICE_THRESHOLD } from "metabase/visualizations/echarts/pie/constants";
import type { ComputedVisualizationSettings } from "metabase/visualizations/types";
import type { RawSeries } from "metabase-types/api";
export const getDefaultShowLegend = () => true;
export const getDefaultShowTotal = () => true;
export const getDefaultPercentVisibility = () => "legend";
export const getDefaultSliceThreshold = () => SLICE_THRESHOLD * 100;
export function getDefaultColors(
rawSeries: RawSeries,
currentSettings: Partial<ComputedVisualizationSettings>,
): ComputedVisualizationSettings["pie.colors"] {
const [
{
data: { rows, cols },
},
] = rawSeries;
const dimensionIndex = cols.findIndex(
col => col.name === currentSettings["pie.dimension"],
);
const dimensionValues = rows.map(r => String(r[dimensionIndex]));
return getColorsForValues(dimensionValues, currentSettings["pie.colors"]);
}

View File

@@ -11,6 +11,7 @@ import { color } from "metabase/lib/colors";
import { getColorsForValues } from "metabase/lib/colors/charts";
import { formatValue } from "metabase/lib/formatting";
import EmbedFrameS from "metabase/public/components/EmbedFrame/EmbedFrame.module.css";
import { SLICE_THRESHOLD } from "metabase/visualizations/echarts/pie/constants";
import {
ChartSettingsError,
MinRowsError,
@@ -24,6 +25,13 @@ import {
getFriendlyName,
computeMaxDecimalsForValues,
} from "metabase/visualizations/lib/utils";
import {
getDefaultShowLegend,
getDefaultPercentVisibility,
getDefaultShowTotal,
getDefaultSliceThreshold,
getDefaultColors,
} from "metabase/visualizations/shared/settings/pie";
import {
getDefaultSize,
getMinSize,
@@ -43,7 +51,6 @@ const MAX_PIE_SIZE = 550;
const INNER_RADIUS_RATIO = 3 / 5;
const PAD_ANGLE = (Math.PI / 180) * 1; // 1 degree in radians
const SLICE_THRESHOLD = 0.025; // approx 1 degree in percentage
const OTHER_SLICE_MIN_PERCENTAGE = 0.003;
export default class PieChart extends Component {
@@ -126,7 +133,7 @@ export default class PieChart extends Component {
section: t`Display`,
title: t`Show legend`,
widget: "toggle",
default: true,
getDefault: getDefaultShowLegend,
inline: true,
marginBottom: "1rem",
},
@@ -134,14 +141,14 @@ export default class PieChart extends Component {
section: t`Display`,
title: t`Show total`,
widget: "toggle",
default: true,
getDefault: getDefaultShowTotal,
inline: true,
},
"pie.percent_visibility": {
section: t`Display`,
title: t`Show percentages`,
widget: "radio",
default: "legend",
getDefault: getDefaultPercentVisibility,
props: {
options: [
{ name: t`Off`, value: "off" },
@@ -154,22 +161,18 @@ export default class PieChart extends Component {
section: t`Display`,
title: t`Minimum slice percentage`,
widget: "number",
default: SLICE_THRESHOLD * 100,
getDefault: getDefaultSliceThreshold,
},
"pie.colors": {
section: t`Display`,
title: t`Colors`,
widget: "colors",
getDefault: (series, settings) =>
settings["pie._dimensionValues"]
? getColorsForValues(settings["pie._dimensionValues"])
: [],
getDefault: getDefaultColors,
getProps: (series, settings) => ({
seriesValues: settings["pie._dimensionValues"] || [],
seriesTitles: settings["pie._dimensionTitles"] || [],
}),
getDisabled: (series, settings) => !settings["pie._dimensionValues"],
readDependencies: ["pie._dimensionValues", "pie._dimensionTitles"],
},
// this setting recomputes color assignment using pie.colors as the existing
// assignments in case the user previous modified pie.colors and a new value

View File

@@ -67,6 +67,7 @@
"leaflet": "^1.2.0",
"leaflet-draw": "^0.4.9",
"leaflet.heat": "^0.2.0",
"lodash.clonedeep": "^4.5.0",
"lodash.debounce": "^4.0.8",
"lodash.orderby": "^4.6.0",
"moment-timezone": "^0.5.38",

View File

@@ -316,7 +316,8 @@
:row_count (:row_count result)
:col_count (count (:cols (:data result)))}))
(def ^:private preview-card-width 400)
;; TODO revert this to 400 before merging to master
(def ^:private preview-card-width 1000)
(api/defendpoint GET "/preview_card_png/:id"
"Get PNG rendering of a Card with `id`."

View File

@@ -110,6 +110,7 @@
(#{:smartscalar
:scalar
:pie
:scatter
:waterfall
:line
@@ -118,9 +119,6 @@
:combo} display-type)
(chart-type :javascript_visualization "display-type is javascript_visualization")
(= display-type :pie)
(chart-type :categorical/donut "result has two cols (%s and %s (number))" (col-description @col-1) (col-description @col-2))
:else
(chart-type :table "no other chart types match")))))

View File

@@ -312,12 +312,6 @@
y-col-settings
(assoc :y y-col-settings))))
(def ^:private colors
"Colors to cycle through for charts. These are copied from https://stats.metabase.com/_internal/colors"
["#509EE3" "#88BF4D" "#A989C5" "#EF8C8C" "#F9D45C" "#F2A86F" "#98D9D9" "#7172AD" "#6450e3" "#4dbf5e"
"#c589b9" "#efce8c" "#b5f95c" "#e35850" "#554dbf" "#bec589" "#8cefc6" "#5cc2f9" "#55e350" "#bf4d4f"
"#89c3c5" "#be8cef" "#f95cd0" "#50e3ae" "#bf974d" "#899bc5" "#ef8cde" "#f95c67"])
(defn format-percentage
"Format a percentage which includes site settings for locale. The first arg is a numeric value to format. The second
is an optional string of decimal and grouping symbols to be used, ie \".,\". There will soon be a values.clj file
@@ -333,103 +327,6 @@
(DecimalFormat. base))]
(.format fmt value))))
(defn- donut-info
"Process rows with a minimum slice threshold. Collapses any segments below the threshold given as a percentage (the
value 25 for 25%) into a single category as \"Other\". "
[threshold-percentage rows]
(let [total (reduce + 0 (map second rows))
threshold (* total (/ threshold-percentage 100))
{as-is true clump false} (group-by (comp #(> % threshold) second) rows)
rows (cond-> as-is
(seq clump)
(conj [(tru "Other") (reduce (fnil + 0) 0 (map second clump))]))]
{:rows rows
:percentages (into {}
(for [[label value] rows]
[label (if (zero? total)
(tru "N/A")
(format-percentage (/ value total)))]))}))
(defn- donut-legend
[legend-entries]
(letfn [(table-fn [entries]
(into [:table {:style (style/style {:color "#4C5773"
:font-family "Lato, sans-serif"
:font-size "24px"
:font-weight "bold"
:box-sizing "border-box"
:white-space "nowrap"})}]
(for [{:keys [label percentage color]} entries]
[:tr {:style (style/style {:margin-right "12px"})}
[:td {:style (style/style {:color color
:padding-right "7px"
:line-height "0"})}
[:span {:style (style/style {:font-size "2.875rem"
:line-height "0"
:position "relative"
:top "-4px"})} "•"]]
[:td {:style (style/style {:padding-right "30px"})}
label]
[:td percentage]])))]
(if (< (count legend-entries) 8)
(table-fn legend-entries)
[:table (into [:tr]
(map (fn [some-entries]
[:td {:style (style/style {:padding-right "20px"
:vertical-align "top"})}
(table-fn some-entries)])
(split-at (/ (count legend-entries) 2) legend-entries)))])))
(defn- replace-nils [rows]
(mapv (fn [row]
(if (nil? (first row))
(assoc row 0 "(empty)")
row))
rows))
(mu/defmethod render :categorical/donut :- formatter/RenderedPulseCard
[_chart-type
render-type
timezone-id :- [:maybe :string]
card
_dashcard
{:keys [rows cols viz-settings] :as data}]
(let [[x-axis-rowfn y-axis-rowfn] (formatter/graphing-column-row-fns card data)
rows (map (juxt (comp str x-axis-rowfn) y-axis-rowfn)
(formatter/row-preprocess x-axis-rowfn y-axis-rowfn (replace-nils rows)))
slice-threshold (or (get viz-settings :pie.slice_threshold)
2.5)
{:keys [rows percentages]} (donut-info slice-threshold rows)
legend-colors (merge (zipmap (map first rows) (cycle colors))
(update-keys (:pie.colors viz-settings) name))
settings {:percent_visibility (:pie.percent_visibility viz-settings) :show_total (:pie.show_total viz-settings)}
image-bundle (image-bundle/make-image-bundle
render-type
(js-svg/categorical-donut rows legend-colors settings))
{label-viz-settings :x} (->js-viz (x-axis-rowfn cols) (y-axis-rowfn cols) viz-settings)]
{:attachments
(when image-bundle
(image-bundle/image-bundle->attachment image-bundle))
:content
[:div
[:img {:style (style/style {:display :block :width :100%})
:src (:image-src image-bundle)}]
(donut-legend
(mapv (fn [row]
(let [label (first row)]
{:percentage (percentages (first row))
:color (legend-colors (first row))
:label (if (and (contains? label-viz-settings :date_style)
(formatter/temporal-string? label))
(formatter/format-temporal-str
timezone-id
(first row)
(x-axis-rowfn cols)
viz-settings)
label)}))
rows))]}))
(mu/defmethod render :progress :- formatter/RenderedPulseCard
[_chart-type
render-type

View File

@@ -150,13 +150,6 @@
(json/generate-string (public-settings/application-colors))))]
(svg-string->bytes svg-string)))
(defn categorical-donut
"Clojure entrypoint to render a categorical donut chart. Rows should be tuples of [category numeric-value]. Returns a
byte array of a png file"
[rows legend-colors settings]
(let [svg-string (.asString (js/execute-fn-name (context) "categorical_donut" rows (seq legend-colors) (json/generate-string settings)))]
(svg-string->bytes svg-string)))
(defn gauge
"Clojure entrypoint to render a gauge chart. Returns a byte array of a png file"
[card data]

View File

@@ -530,37 +530,54 @@
(is (= (map :key (get-in funnel-card [:visualization_settings :funnel.rows]))
section-labels))))))))
(deftest render-categorical-donut-test
(let [columns [{:name "category",
:display_name "Category",
:base_type :type/Text
:semantic_type nil}
{:name "NumPurchased",
:display_name "NumPurchased",
:base_type :type/Integer
:semantic_type nil}]
render (fn [rows & [viz-settings]]
(body/render :categorical/donut :inline pacific-tz
render.tu/test-card
nil
{:cols columns :rows rows :viz-settings viz-settings}))
prune (fn prune [html-tree]
(walk/prewalk (fn no-maps [x]
(if (vector? x)
(filterv (complement map?) x)
x))
html-tree))]
(testing "Renders without error"
(let [rendered-info (render [[nil 10] ["Doohickey" 65] ["Widget" 25]] {:show_values true})]
(is (has-inline-image? rendered-info))))
(testing "Includes percentages"
(is (= [:div
[:img]
[:table
[:tr [:td [:span "•"]] [:td "(empty)"] [:td "10%"]]
[:tr [:td [:span "•"]] [:td "Doohickey"] [:td "65%"]]
[:tr [:td [:span "•"]] [:td "Widget"] [:td "25%"]]]]
(prune (:content (render [[nil 10] ["Doohickey" 65] ["Widget" 25]]))))))))
(deftest render-pie-chart-test
(testing "The static-viz pie chart renders correctly."
(mt/dataset test-data
(let [q {:database (mt/id)
:type :query
:query
{:source-table (mt/id :products)
:aggregation [[:count]]
:breakout [[:field (mt/id :products :category) {:base-type :type/Text}]]}}
colours {:Doohickey "honeydew"
:Gadget "slategray"
:Gizmo "goldenrod"
:Widget "hotpink"}]
(mt/with-temp [:model/Card {card-a-id :id} {:name "not-a-crumble"
:display :pie
:visualization_settings {:pie.colors colours}
:dataset_query q}
:model/Card {card-b-id :id} {:name "maybe-a-donut"
:display :pie
:visualization_settings {:pie.show_legend false
:pie.show_total false
:pie.colors colours}
:dataset_query q}]
(let [card-a-doc (render.tu/render-card-as-hickory card-a-id)
card-b-doc (render.tu/render-card-as-hickory card-b-id)]
;; The test asserts that all 4 slices exist by seeing that each path element has the colour assigned to that category
;; we should expect to see each of the 4 (and only those 4) colours.
;; This is also true of the colours for the legend circle elements.
;; When legend and Totals are disabled, we should expect those elements not to exist in the render
(doseq [[doc test-str expectations] [[card-a-doc "Renders with legend and 'total'."
{:legend-els-colours ["honeydew" "slategray" "goldenrod" "hotpink"]
:slice-els-colours ["honeydew" "slategray" "goldenrod" "hotpink"]
:total-els-text ["TOTAL"]}]
[card-b-doc "Renders without legend and 'total' when disabled in viz-settings."
{:legend-els-colours []
:slice-els-colours ["honeydew" "slategray" "goldenrod" "hotpink"]
:total-els-text []}]]]
(let [legend-elements (->> (hik.s/select (hik.s/tag :circle) doc)
(map #(get-in % [:attrs :fill])))
slice-elements (->> (hik.s/select (hik.s/tag :path) doc)
(map #(get-in % [:attrs :fill])))
total-elements (->> (hik.s/select (hik.s/find-in-text #"TOTAL") doc)
(map (fn [el] (-> el :content first))))]
(testing test-str
(is (= expectations
{:legend-els-colours legend-elements
:slice-els-colours slice-elements
:total-els-text total-elements})))))))))))
(deftest render-progress
(let [col [{:name "NumPurchased",
@@ -579,23 +596,6 @@
(let [rendered-info (render [[-25]])]
(is (has-inline-image? rendered-info))))))
(def donut-info #'body/donut-info)
(deftest ^:parallel donut-info-test
(let [rows [["a" 45] ["b" 45] ["c" 5] ["d" 5]]]
(testing "If everything is above the threshold does nothing"
(is (= rows (:rows (donut-info 4 rows)))))
(testing "Collapses smaller sections below threshold"
(is (= [["a" 45] ["b" 45] ["Other" 10]]
(:rows (donut-info 5 rows)))))
(testing "Computes percentages"
(is (= {"a" "45%" "b" "45%" "Other" "10%"}
(:percentages (donut-info 5 rows)))))
(testing "Includes zero percent rows"
(let [rows [["a" 50] ["b" 50] ["d" 0]]]
(is (= {"a" "50%" "b" "50%" "Other" "0%"}
(:percentages (donut-info 5 rows))))))))
(deftest ^:parallel format-percentage-test
(are [value expected] (= expected
(body/format-percentage 12345.4321 value))

View File

@@ -78,17 +78,6 @@
(is (no-html-elements tag-set) (str "Contained html elements: "
(set/intersection #{"div" "span" "p"}))))))
(deftest categorical-donut-test
(let [rows [["apples" 2]
["bananas" 3]]
colors {"apples" "red" "bananas" "yellow"}
settings {:show_values true}]
(testing "It returns bytes"
(let [svg-bytes (js-svg/categorical-donut rows colors settings)]
(is (bytes? svg-bytes))))
(let [svg-string (.asString ^Value (js/execute-fn-name context "categorical_donut" rows (seq colors) (json/generate-string settings)))]
(validate-svg-string :categorical/donut svg-string))))
(deftest progress-test
(let [value 1234
goal 1337

View File

@@ -115,8 +115,8 @@
{:base_type :type/Number}]
:rows [["A" 2]
["B" 3]]})))))
(testing "Pie charts are correctly identified and return `:categorical/donut`."
(is (= :categorical/donut
(testing "Pie charts are correctly identified and return `:javascript_visualization`."
(is (= :javascript_visualization
(render/detect-pulse-chart-type {:display :pie}
{}
{:cols [{:base_type :type/Text}

View File

@@ -15484,6 +15484,11 @@ lodash._topath@^3.0.0:
dependencies:
lodash.isarray "^3.0.0"
lodash.clonedeep@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
integrity sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==
lodash.debounce@^4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"