Skip to content
Snippets Groups Projects
Unverified Commit fb35f2b5 authored by Aleksandr Lesnenko's avatar Aleksandr Lesnenko Committed by GitHub
Browse files

Pie chart labels (#25009)

* pie labels

* hide labels when no space for them

* unused class

* review

* fix resizing issue, add visual test

* mute invalid typings
parent c89a9292
Branches
Tags
No related merge requests found
Showing
with 322 additions and 59 deletions
......@@ -8,4 +8,5 @@ export {
hueRotate,
isLight,
isDark,
getTextColorForBackground,
} from "./palette";
......@@ -125,3 +125,55 @@ export const isLight = (c: string) => {
export const isDark = (c: string) => {
return Color(color(c)).isDark();
};
const LIGHT_HSL_RANGES = [
[
[42, 105],
[70, 100],
[75, 100],
],
[
[140, 185],
[70, 100],
[75, 100],
],
[
[40, 120],
[70, 100],
[70, 100],
],
[
[40, 110],
[90, 100],
[0, 100],
],
[
[150, 185],
[90, 100],
[0, 100],
],
];
export const getTextColorForBackground = (backgroundColor: string) => {
const colorObject = Color(color(backgroundColor));
const hslColor = [
colorObject.hue(),
colorObject.saturationl(),
colorObject.lightness(),
];
if (
LIGHT_HSL_RANGES.some(hslRanges => {
return hslRanges.every((range, index) => {
const [start, end] = range;
const colorComponentValue = hslColor[index];
return colorComponentValue >= start && colorComponentValue <= end;
});
})
) {
return color("text-dark");
}
return color("white");
};
:local .ChartWithLegend {
display: flex;
justify-content: flex-end;
width: 100%;
height: 100%;
}
:local .ChartWithLegend .Legend {
......
import styled from "@emotion/styled";
export const Label = styled.text`
pointer-events: none;
text-anchor: middle;
font-weight: bold;
`;
import React, { SVGAttributes, useEffect, useRef, useState } from "react";
import d3 from "d3";
import { getTextColorForBackground } from "metabase/lib/colors";
import { Label } from "./PieArc.styled";
import { getMaxLabelDimension } from "./utils";
const LABEL_PADDING = 4;
interface PieArcProps extends SVGAttributes<SVGPathElement> {
d3Arc: d3.svg.Arc<d3.svg.arc.Arc>;
slice: d3.svg.arc.Arc;
label?: string;
labelFontSize: number;
shouldRenderLabel?: boolean;
}
export const PieArc = ({
d3Arc,
slice,
label,
labelFontSize,
shouldRenderLabel,
...rest
}: PieArcProps) => {
const [isLabelVisible, setIsLabelVisible] = useState(false);
const labelRef = useRef<SVGTextElement>(null);
const labelTransform = `translate(${d3Arc.centroid(slice)})`;
useEffect(() => {
if (!shouldRenderLabel) {
return;
}
const maxDimension = getMaxLabelDimension(d3Arc, slice);
const dimensions = labelRef.current?.getBoundingClientRect();
if (!dimensions) {
return;
}
const isLabelVisible =
dimensions.width + LABEL_PADDING <= maxDimension &&
dimensions.height + LABEL_PADDING <= maxDimension;
setIsLabelVisible(isLabelVisible);
}, [d3Arc, shouldRenderLabel, slice]);
const labelColor = rest.fill && getTextColorForBackground(rest.fill);
return (
<>
<path data-testid="slice" d={d3Arc(slice)} {...rest} />
{shouldRenderLabel && label != null && (
<Label
style={{ visibility: isLabelVisible ? "visible" : "hidden" }}
fontSize={labelFontSize}
ref={labelRef}
dy={4}
transform={labelTransform}
fill={labelColor}
>
{label}
</Label>
)}
</>
);
};
......@@ -12,11 +12,8 @@
:local .Chart {
display: flex;
}
:local .Donut {
flex: 1;
height: 100%;
align-items: center;
justify-content: center;
}
:local .Detail {
......
/* eslint-disable react/prop-types */
import React, { Component } from "react";
import cx from "classnames";
import d3 from "d3";
import _ from "underscore";
import styles from "./PieChart.css";
import { t } from "ttag";
import ChartTooltip from "../components/ChartTooltip";
import ChartWithLegend from "../components/ChartWithLegend";
import ChartTooltip from "../../components/ChartTooltip";
import ChartWithLegend from "../../components/ChartWithLegend";
import {
ChartSettingsError,
......@@ -24,14 +27,13 @@ import { formatValue } from "metabase/lib/formatting";
import { color } from "metabase/lib/colors";
import { getColorsForValues } from "metabase/lib/colors/charts";
import cx from "classnames";
import d3 from "d3";
import _ from "underscore";
import { PieArc } from "./PieArc";
const SIDE_PADDING = 24;
const MAX_LABEL_FONT_SIZE = 20;
const MIN_LABEL_FONT_SIZE = 14;
const MAX_PIE_SIZE = 550;
const OUTER_RADIUS = 50; // within 100px canvas
const INNER_RADIUS_RATIO = 3 / 5;
const PAD_ANGLE = (Math.PI / 180) * 1; // 1 degree in radians
......@@ -44,6 +46,9 @@ export default class PieChart extends Component {
constructor(props) {
super(props);
this.state = { width: 0, height: 0 };
this.chartContainer = React.createRef();
this.chartDetail = React.createRef();
this.chartGroup = React.createRef();
}
......@@ -123,6 +128,12 @@ export default class PieChart extends Component {
widget: "toggle",
default: true,
},
"pie.show_data_labels": {
section: t`Display`,
title: t`Show data labels`,
widget: "toggle",
default: false,
},
"pie.slice_threshold": {
section: t`Display`,
title: t`Minimum slice percentage`,
......@@ -221,7 +232,27 @@ export default class PieChart extends Component {
},
};
componentDidUpdate() {
updateChartViewportSize = () => {
requestAnimationFrame(() => {
if (!this.chartContainer.current) {
return;
}
const { width, height } =
this.chartContainer.current.getBoundingClientRect();
this.setState({
width,
height,
});
});
};
componentDidMount() {
this.updateChartViewportSize();
}
componentDidUpdate(prevProps) {
requestAnimationFrame(() => {
const groupElement = this.chartGroup.current;
const detailElement = this.chartDetail.current;
......@@ -231,6 +262,13 @@ export default class PieChart extends Component {
detailElement.classList.remove("hide");
}
});
if (
prevProps.width !== this.props.width ||
prevProps.height !== this.props.height
) {
this.updateChartViewportSize();
}
}
render() {
......@@ -245,6 +283,8 @@ export default class PieChart extends Component {
settings,
} = this.props;
const { width, height } = this.state;
const [
{
data: { cols, rows },
......@@ -310,11 +350,17 @@ export default class PieChart extends Component {
slices.push(otherSlice);
}
const decimals = computeMaxDecimalsForValues(
slices.map(s => s.percentage),
{ style: "percent", maximumSignificantDigits: 3 },
);
const formatPercent = percent =>
const percentages = slices.map(s => s.percentage);
const legendDecimals = computeMaxDecimalsForValues(percentages, {
style: "percent",
maximumSignificantDigits: 3,
});
const labelsDecimals = computeMaxDecimalsForValues(percentages, {
style: "percent",
maximumSignificantDigits: 2,
});
const formatPercent = (percent, decimals) =>
formatValue(percent, {
column: cols[metricIndex],
number_separators: settings.column(cols[metricIndex]).number_separators,
......@@ -327,7 +373,7 @@ export default class PieChart extends Component {
const legendTitles = slices.map(slice => [
slice.key === "Other" ? slice.key : formatDimension(slice.key, true),
settings["pie.show_legend_perecent"]
? formatPercent(slice.percentage)
? formatPercent(slice.percentage, legendDecimals)
: undefined,
]);
const legendColors = slices.map(slice => slice.color);
......@@ -342,6 +388,14 @@ export default class PieChart extends Component {
slices.push(otherSlice);
}
const side = Math.min(Math.min(width, height) - SIDE_PADDING, MAX_PIE_SIZE);
const outerRadius = side / 2;
const labelFontSize = Math.max(
MAX_LABEL_FONT_SIZE * (side / MAX_PIE_SIZE),
MIN_LABEL_FONT_SIZE,
);
/** @type {d3.layout.Pie<typeof slices[number]>} */
const pie = d3.layout
.pie()
.sort(null)
......@@ -349,8 +403,8 @@ export default class PieChart extends Component {
.value(d => d.value);
const arc = d3.svg
.arc()
.outerRadius(OUTER_RADIUS)
.innerRadius(OUTER_RADIUS * INNER_RADIUS_RATIO);
.outerRadius(outerRadius)
.innerRadius(outerRadius * INNER_RADIUS_RATIO);
function hoverForIndex(index, event) {
const slice = slices[index];
......@@ -383,7 +437,7 @@ export default class PieChart extends Component {
? [
{
key: t`Percentage`,
value: formatPercent(slice.percentage),
value: formatPercent(slice.percentage, legendDecimals),
},
]
: [],
......@@ -434,6 +488,8 @@ export default class PieChart extends Component {
const getSliceIsClickable = index =>
isClickable && slices[index] !== otherSlice;
const shouldRenderLabels = settings["pie.show_data_labels"];
return (
<ChartWithLegend
className={className}
......@@ -461,47 +517,63 @@ export default class PieChart extends Component {
</div>
<div className={styles.Title}>{title}</div>
</div>
<div className={cx(styles.Chart, "layout-centered")}>
<div
ref={this.chartContainer}
className={cx(styles.Chart, "layout-centered")}
>
<svg
data-testid="pie-chart"
className={cx(styles.Donut, "m1")}
viewBox="0 0 100 100"
width={side}
height={side}
style={{ maxWidth: MAX_PIE_SIZE, maxHeight: MAX_PIE_SIZE }}
>
<g ref={this.chartGroup} transform="translate(50,50)">
{pie(slices).map((slice, index) => (
<path
data-testid="slice"
key={index}
d={arc(slice)}
fill={slices[index].color}
opacity={
hovered &&
hovered.index != null &&
hovered.index !== index
? 0.3
: 1
}
onMouseMove={e =>
onHoverChange && onHoverChange(hoverForIndex(index, e))
}
onMouseLeave={() => onHoverChange && onHoverChange(null)}
className={cx({
"cursor-pointer": getSliceIsClickable(index),
})}
onClick={
// We use a ternary here because using
// `condition && function` yields a console warning.
getSliceIsClickable(index)
? e =>
onVisualizationClick({
...getSliceClickObject(index),
event: event.nativeEvent,
})
: undefined
}
/>
))}
<g
ref={this.chartGroup}
transform={`translate(${outerRadius},${outerRadius})`}
>
{pie(slices).map((slice, index) => {
const label = formatPercent(
slice.data.percentage,
labelsDecimals,
);
return (
<PieArc
key={index}
shouldRenderLabel={shouldRenderLabels}
d3Arc={arc}
slice={slice}
label={label}
labelFontSize={labelFontSize}
fill={slice.data.color}
opacity={
hovered &&
hovered.index != null &&
hovered.index !== index
? 0.3
: 1
}
onMouseMove={e =>
onHoverChange?.(hoverForIndex(index, e))
}
onMouseLeave={() => onHoverChange?.(null)}
className={cx({
"cursor-pointer": getSliceIsClickable(index),
})}
onClick={
// We use a ternary here because using
// `condition && function` yields a console warning.
getSliceIsClickable(index)
? e =>
onVisualizationClick({
...getSliceClickObject(index),
event: e.nativeEvent,
})
: undefined
}
/>
);
})}
</g>
</svg>
</div>
......
export { default } from "./PieChart";
export function getMaxLabelDimension(
d3Arc: d3.svg.Arc<d3.svg.arc.Arc>,
slice: d3.svg.arc.Arc,
) {
// Invalid typing
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const innerRadius = d3Arc.innerRadius()();
// Invalid typing
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const outerRadius = d3Arc.outerRadius()();
const donutWidth = outerRadius - innerRadius;
const arcAngle = slice.startAngle - slice.endAngle;
// using law of cosines to calculate the arc length
// c = sqrt(a^2 + b^2﹣2*a*b * cos(arcAngle))
// where a = b = innerRadius
const innerRadiusArcDistance = Math.sqrt(
2 * innerRadius * innerRadius -
2 * innerRadius * innerRadius * Math.cos(arcAngle),
);
return Math.min(innerRadiusArcDistance, donutWidth);
}
import { restore, visitQuestionAdhoc } from "__support__/e2e/helpers";
import { SAMPLE_DB_ID } from "__support__/e2e/cypress_data";
describe("visual tests > visualizations > pie", () => {
beforeEach(() => {
restore();
cy.signInAsNormalUser();
});
it("with labels", () => {
const testQuery = {
type: "native",
native: {
query:
"select 1 x, 1000 y\n" +
"union all select 2 x, 800 y\n" +
"union all select 3 x, 600 y\n" +
"union all select 4 x, 200 y\n" +
"union all select 5 x, 10 y\n",
},
database: SAMPLE_DB_ID,
};
visitQuestionAdhoc({
dataset_query: testQuery,
display: "pie",
visualization_settings: {
"pie.show_data_labels": true,
"pie.dimension": "X",
"pie.metric": "Y",
},
});
cy.findByText("2,610");
cy.createPercySnapshot();
});
});
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment