Compare commits
9 Commits
python-run
...
echarts-pi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5815b79079 | ||
|
|
c9fa3fbfe1 | ||
|
|
602a1dda3e | ||
|
|
001ff3700d | ||
|
|
a02ecf943b | ||
|
|
b2fd8a04df | ||
|
|
3f680f1b0e | ||
|
|
dfbb8d50ab | ||
|
|
afb21d0e3d |
1
.github/workflows/uberjar.yml
vendored
1
.github/workflows/uberjar.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- "master"
|
||||
- "echarts-pie" # TODO: remove this before merging to master
|
||||
paths-ignore:
|
||||
# config files
|
||||
- ".**"
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from "./CategoricalDonutChart";
|
||||
@@ -1,10 +0,0 @@
|
||||
export const DEFAULT = {
|
||||
data: [
|
||||
["donut", 2000],
|
||||
["cronut", 3100],
|
||||
],
|
||||
colors: {
|
||||
donut: "#509EE3",
|
||||
cronut: "#DDECFA",
|
||||
},
|
||||
};
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,5 @@
|
||||
import defaultSettings from "./default-settings.json";
|
||||
|
||||
export const data = {
|
||||
defaultSettings,
|
||||
};
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
47
frontend/src/metabase/visualizations/echarts/pie/format.ts
Normal file
47
frontend/src/metabase/visualizations/echarts/pie/format.ts
Normal 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 };
|
||||
}
|
||||
131
frontend/src/metabase/visualizations/echarts/pie/model/index.ts
Normal file
131
frontend/src/metabase/visualizations/echarts/pie/model/index.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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),
|
||||
},
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
30
frontend/src/metabase/visualizations/shared/settings/pie.ts
Normal file
30
frontend/src/metabase/visualizations/shared/settings/pie.ts
Normal 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"]);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`."
|
||||
|
||||
@@ -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")))))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user