Skip to content
Snippets Groups Projects
Unverified Commit 41b4b687 authored by Anton Kulyk's avatar Anton Kulyk Committed by GitHub
Browse files

Toggle series visibility from chart legend (pie charts) (#47581)

* Add series visibility props to `ChartWithLegend`

* Mark pie slices as visible or hidden

* Don't let to hide the last visible slice

* Add space between legend dot and title

* Fix legend dot outer circle is clipped by overflow

* Use `ChartSettingSeriesOrder` for `pie.rows` setting

* Actually hide slices and exclude from total calc

* Don't show "0%" next to hidden slice legend items

* Hide the "Other" slice

* Make overflow slices popover interactive

* Fix hover

* Fix incorrect visibility state in legend popover

* Add e2e test

* Remove debug stuff

* Fix hover and drill issues with slice index calc

* Fix `pie.rows` ordering bug

* Revert "Fix hover and drill issues with slice index calc"

This reverts commit f104aaa5.

* Fix hover and drill issues with slice index calc (2)

`dataIndex` based approach

* Use a special data key for the "Other" slice

* Fixed viewport size in legend e2e test

* Fix jumping legend width

* Fix dot and label vertical alignment

* Rework pie e2e test

* Fix legend dot and label alignment
parent c1b850ef
No related branches found
No related tags found
No related merge requests found
Showing
with 386 additions and 166 deletions
......@@ -8,6 +8,7 @@ import {
getDashboardCard,
leftSidebar,
modal,
pieSlices,
popover,
restore,
scatterBubbleWithColor,
......@@ -68,6 +69,19 @@ const MANY_LEGEND_ITEMS_QUESTION = {
},
};
const PIE_CHART_QUESTION = {
name: "pie chart",
display: "pie",
query: {
"source-table": ORDERS_ID,
aggregation: [["count"]],
breakout: [JOINED_PEOPLE_STATE_FIELD_REF],
},
visualization_settings: {
"pie.slice_threshold": 4,
},
};
const SPLIT_AXIS_QUESTION = {
name: "two aggregations + split axis + trendline",
display: "combo",
......@@ -117,6 +131,7 @@ describe("scenarios > visualizations > legend", () => {
MANY_LEGEND_ITEMS_QUESTION,
SPLIT_AXIS_QUESTION,
SCATTER_VIZ_QUESTION,
PIE_CHART_QUESTION,
],
cards: [
{
......@@ -143,6 +158,12 @@ describe("scenarios > visualizations > legend", () => {
size_x: 24,
size_y: 6,
},
{
col: 0,
row: 24,
size_x: 24,
size_y: 5,
},
],
}).then(({ dashboard }) => visitDashboard(dashboard.id));
......@@ -313,6 +334,33 @@ describe("scenarios > visualizations > legend", () => {
});
});
getDashboardCard(4).within(() => {
cy.findByText("18,760").should("exist"); // total value
pieSlices().should("have.length", 4);
getPieChartLegendItemPercentage("TX").should("have.text", "7.15%");
hideSeries(0); // TX (Texas)
pieSlices().should("have.length", 3);
cy.findByText("18,760").should("not.exist");
cy.findByText("17,418").should("exist");
getPieChartLegendItemPercentage("TX").should("not.exist");
hideSeries(3); // "Other" slice
pieSlices().should("have.length", 2);
cy.findByText("17,418").should("not.exist");
cy.findByText("1,660").should("exist");
getPieChartLegendItemPercentage("Other").should("not.exist");
getPieChartLegendItemPercentage("MT").should("have.text", "52.5%");
getPieChartLegendItemPercentage("MN").should("have.text", "47.5%");
showSeries(0);
pieSlices().should("have.length", 3);
getPieChartLegendItemPercentage("TX").should("have.text", "44.7%");
});
// Ensure can't toggle series visibility in edit mode
editDashboard();
......@@ -518,3 +566,9 @@ function showSeries(legendItemIndex) {
.findByLabelText("Show series")
.click();
}
function getPieChartLegendItemPercentage(sliceName) {
// ChartWithLegend actually renders two legend elements for visual balance
// https://github.com/metabase/metabase/blob/9053d6fe2b8a9500e67559d35d39259a8a87c4f6/frontend/src/metabase/visualizations/components/ChartWithLegend.jsx#L140
return cy.findAllByTestId(`legend-item-${sliceName}`).eq(0).children().eq(1);
}
......@@ -25,6 +25,7 @@ export function PieChart({
const chartModel = getPieChartModel(
rawSeries,
computedVizSettings,
[],
renderingContext,
);
const formatters = getPieChartFormatters(
......
import {
DIMENSIONS,
OTHER_SLICE_KEY,
} from "metabase/visualizations/echarts/pie/constants";
import { DIMENSIONS } from "metabase/visualizations/echarts/pie/constants";
import type { PieChartFormatters } from "metabase/visualizations/echarts/pie/format";
import type { PieChartModel } from "metabase/visualizations/echarts/pie/model/types";
import type { ComputedVisualizationSettings } from "metabase/visualizations/types";
......@@ -22,22 +19,16 @@ export function getPieChartLegend(
} = calculateLegendRowsWithColumns({
items: chartModel.slices
.filter(s => s.data.includeInLegend)
.map(s => {
const label = s.data.isOther
? OTHER_SLICE_KEY // need to use this instead of `s.data.key` to ensure type is string
: s.data.name;
return {
name: label,
percent:
settings["pie.percent_visibility"] === "legend" ||
settings["pie.percent_visibility"] === "both"
? formatters.formatPercent(s.data.normalizedPercentage, "legend")
: undefined,
color: s.data.color,
key: String(s.data.key),
};
}),
.map(s => ({
name: s.data.name,
percent:
settings["pie.percent_visibility"] === "legend" ||
settings["pie.percent_visibility"] === "both"
? formatters.formatPercent(s.data.normalizedPercentage, "legend")
: undefined,
color: s.data.color,
key: String(s.data.key),
})),
width: DIMENSIONS.maxSideLength,
horizontalPadding: DIMENSIONS.padding.side,
});
......
......@@ -25,6 +25,7 @@ class ChartWithLegend extends Component {
let {
children,
legendTitles,
legendHiddenIndices,
legendColors,
hovered,
onHoverChange,
......@@ -36,6 +37,7 @@ class ChartWithLegend extends Component {
width,
showLegend,
isDashboard,
onToggleSeriesVisibility,
} = this.props;
// padding
......@@ -101,9 +103,11 @@ class ChartWithLegend extends Component {
<LegendComponent
className={styles.Legend}
titles={legendTitles}
hiddenIndices={legendHiddenIndices}
colors={legendColors}
hovered={hovered}
onHoverChange={onHoverChange}
onToggleSeriesVisibility={onToggleSeriesVisibility}
/>
) : null;
......
......@@ -9,12 +9,33 @@ import LegendItem from "./LegendItem";
export default class LegendHorizontal extends Component {
render() {
const { className, titles, colors, hovered, onHoverChange } = this.props;
const {
className,
titles,
colors,
hiddenIndices,
hovered,
onHoverChange,
onToggleSeriesVisibility,
} = this.props;
return (
<ol className={cx(className, LegendS.Legend, LegendS.horizontal)}>
{titles.map((title, index) => {
const isMuted =
hovered && hovered.index != null && index !== hovered.index;
const isVisible = !hiddenIndices.includes(index);
const handleMouseEnter = () => {
onHoverChange?.({
index,
element: ReactDOM.findDOMNode(this.refs["legendItem" + index]),
});
};
const handleMouseLeave = () => {
onHoverChange?.(null);
};
return (
<li
key={index}
......@@ -26,17 +47,22 @@ export default class LegendHorizontal extends Component {
title={title}
color={colors[index % colors.length]}
isMuted={isMuted}
isVisible={isVisible}
showTooltip={false}
onMouseEnter={() =>
onHoverChange &&
onHoverChange({
index,
element: ReactDOM.findDOMNode(
this.refs["legendItem" + index],
),
})
}
onMouseLeave={() => onHoverChange && onHoverChange(null)}
onMouseEnter={() => {
if (isVisible) {
handleMouseEnter();
}
}}
onMouseLeave={handleMouseLeave}
onToggleSeriesVisibility={event => {
if (isVisible) {
handleMouseLeave();
} else {
handleMouseEnter();
}
onToggleSeriesVisibility(event, index);
}}
/>
</li>
);
......
......@@ -4,14 +4,14 @@ import PropTypes from "prop-types";
import { Component } from "react";
import { Ellipsified } from "metabase/core/components/Ellipsified";
import Tooltip from "metabase/core/components/Tooltip";
import CS from "metabase/css/core/index.css";
import DashboardS from "metabase/css/dashboard.module.css";
import EmbedFrameS from "metabase/public/components/EmbedFrame/EmbedFrame.module.css";
import { Icon } from "metabase/ui";
import { Icon, Tooltip } from "metabase/ui";
import LegendS from "./Legend.module.css";
import { IconContainer } from "./LegendItem.styled";
import { LegendItemDot } from "./legend/LegendItemDot";
const propTypes = {
icon: PropTypes.object,
......@@ -26,6 +26,7 @@ export default class LegendItem extends Component {
static defaultProps = {
showDot: true,
showTitle: true,
isVisible: true,
isMuted: false,
showTooltip: true,
showDotTooltip: true,
......@@ -38,6 +39,7 @@ export default class LegendItem extends Component {
icon,
showDot,
showTitle,
isVisible,
isMuted,
showTooltip,
showDotTooltip,
......@@ -47,6 +49,7 @@ export default class LegendItem extends Component {
description,
onClick,
infoClassName,
onToggleSeriesVisibility,
} = this.props;
return (
......@@ -70,6 +73,7 @@ export default class LegendItem extends Component {
style={{
overflowX: "hidden",
flex: "0 1 auto",
paddingLeft: showDot ? "4px" : "0",
}}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
......@@ -81,21 +85,23 @@ export default class LegendItem extends Component {
</IconContainer>
)}
{showDot && (
<Tooltip tooltip={title} isEnabled={showTooltip && showDotTooltip}>
<div
className={cx(CS.flexNoShrink, CS.inlineBlock, CS.circular)}
style={{
width: 13,
height: 13,
margin: 4,
marginRight: 8,
backgroundColor: color,
}}
<Tooltip
label={title}
disabled={!showTooltip || !showDotTooltip}
arrowPosition="center"
>
<LegendItemDot
color={color}
isVisible={isVisible}
onClick={onToggleSeriesVisibility}
/>
</Tooltip>
)}
{showTitle && (
<div className={cx(CS.flex, CS.alignCenter, CS.overflowHidden)}>
<div
className={cx(CS.flex, CS.alignCenter, CS.overflowHidden)}
style={showDot && { marginLeft: "4px" }}
>
<Ellipsified showTooltip={showTooltip}>{title}</Ellipsified>
{description && (
<div
......
/* eslint-disable react/prop-types */
/* eslint-disable react/no-string-refs */
import cx from "classnames";
import { Component } from "react";
import { Component, createRef } from "react";
import ReactDOM from "react-dom";
import { t } from "ttag";
import Tooltip from "metabase/core/components/Tooltip";
import CS from "metabase/css/core/index.css";
import { Popover } from "metabase/ui";
import LegendS from "./Legend.module.css";
import LegendItem from "./LegendItem";
export default class LegendVertical extends Component {
constructor(props, context) {
super(props, context);
this.state = {
overflowCount: 0,
size: null,
};
}
static propTypes = {};
static defaultProps = {};
state = {
overflowCount: 0,
size: null,
listWidth: null,
};
listRef = createRef();
componentDidMount() {
const listWidth = this.listRef.current.getBoundingClientRect().width;
this.setState({ listWidth });
}
componentDidUpdate(prevProps, prevState) {
// Get the bounding rectangle of the chart widget to determine if
// legend items will overflow the widget area
......@@ -50,10 +55,32 @@ export default class LegendVertical extends Component {
this.setState({ overflowCount, size });
}
}
const [previousSampleTitle] = prevProps.titles || [];
const [sampleTitle] = this.props.titles || [];
if (
sampleTitle &&
previousSampleTitle &&
previousSampleTitle.length !== sampleTitle.length
) {
this.setState({ listWidth: null }, () => {
const listWidth = this.listRef.current.getBoundingClientRect().width;
this.setState({ listWidth });
});
}
}
render() {
const { className, titles, colors, hovered, onHoverChange } = this.props;
const {
className,
titles,
colors,
hovered,
hiddenIndices = [],
onHoverChange,
onToggleSeriesVisibility,
} = this.props;
const { overflowCount } = this.state;
let items, extraItems, extraColors;
if (overflowCount > 0) {
......@@ -66,26 +93,39 @@ export default class LegendVertical extends Component {
items = titles;
}
return (
<ol className={cx(className, LegendS.Legend, LegendS.vertical)}>
<ol
className={cx(className, LegendS.Legend, LegendS.vertical)}
style={{ width: this.state.listWidth }}
ref={this.listRef}
>
{items.map((title, index) => {
const isMuted =
hovered && hovered.index != null && index !== hovered.index;
const legendItemTitle = Array.isArray(title) ? title[0] : title;
const isVisible = !hiddenIndices.includes(index);
const handleMouseEnter = () => {
onHoverChange?.({
index,
element: ReactDOM.findDOMNode(this.refs["legendItem" + index]),
});
};
const handleMouseLeave = () => {
onHoverChange?.();
};
return (
<li
key={index}
ref={"item" + index}
className={cx(CS.flex, CS.flexNoShrink)}
onMouseEnter={e =>
onHoverChange &&
onHoverChange({
index,
element: ReactDOM.findDOMNode(
this.refs["legendItem" + index],
),
})
}
onMouseLeave={e => onHoverChange && onHoverChange()}
onMouseEnter={e => {
if (isVisible) {
handleMouseEnter();
}
}}
onMouseLeave={handleMouseLeave}
data-testid={`legend-item-${legendItemTitle}`}
{...(hovered && { "aria-current": !isMuted })}
>
......@@ -94,7 +134,16 @@ export default class LegendVertical extends Component {
title={legendItemTitle}
color={colors[index % colors.length]}
isMuted={isMuted}
isVisible={isVisible}
showTooltip={false}
onToggleSeriesVisibility={event => {
if (isVisible) {
handleMouseLeave();
} else {
handleMouseEnter();
}
onToggleSeriesVisibility(event, index);
}}
/>
{Array.isArray(title) && (
<span
......@@ -114,23 +163,30 @@ export default class LegendVertical extends Component {
);
})}
{overflowCount > 0 ? (
<li key="extra" className={cx(CS.flex, CS.flexNoShrink)}>
<Tooltip
tooltip={
<LegendVertical
className={CS.p2}
titles={extraItems}
colors={extraColors}
<Popover>
<Popover.Target>
<li className={cx(CS.flex, CS.flexNoShrink, CS.cursorPointer)}>
<LegendItem
title={overflowCount + 1 + " " + t`more`}
color="gray"
showTooltip={false}
/>
}
>
<LegendItem
title={overflowCount + 1 + " " + t`more`}
color="gray"
showTooltip={false}
</li>
</Popover.Target>
<Popover.Dropdown>
<LegendVertical
className={CS.p2}
titles={extraItems}
colors={extraColors}
hiddenIndices={hiddenIndices
.filter(i => i >= items.length - 1)
.map(i => i - items.length)}
onToggleSeriesVisibility={(event, sliceIndex) =>
onToggleSeriesVisibility(event, sliceIndex + items.length)
}
/>
</Tooltip>
</li>
</Popover.Dropdown>
</Popover>
) : null}
</ol>
);
......
import { type Ref, forwardRef } from "react";
import { t } from "ttag";
import {
......@@ -13,16 +14,16 @@ interface LegendItemDotProps {
onClick?: () => void;
}
export function LegendItemDot({
isVisible = true,
color,
onClick,
}: LegendItemDotProps) {
export const LegendItemDot = forwardRef<
HTMLButtonElement | HTMLDivElement,
LegendItemDotProps
>(function LegendItemDot({ isVisible = true, color, onClick }, ref) {
if (onClick) {
return (
<RootButton
aria-label={isVisible ? t`Hide series` : t`Show series`}
onClick={onClick}
ref={ref as Ref<HTMLButtonElement>}
>
<OuterCircle />
<InnerCircle color={color} isVisible={isVisible} />
......@@ -31,9 +32,9 @@ export function LegendItemDot({
}
return (
<Root data-testid="legend-item-dot">
<Root data-testid="legend-item-dot" ref={ref as Ref<HTMLDivElement>}>
<OuterCircle />
<InnerCircle color={color} isVisible={isVisible} />
</Root>
);
}
});
......@@ -33,6 +33,7 @@ interface ChartSettingSeriesOrderProps {
series: Series;
hasEditSettings: boolean;
onChangeSeriesColor: (seriesKey: string, color: string) => void;
onSortEnd: (newItems: SortableItem[]) => void;
}
export const ChartSettingSeriesOrder = ({
......@@ -41,6 +42,7 @@ export const ChartSettingSeriesOrder = ({
onShowWidget,
hasEditSettings = true,
onChangeSeriesColor,
onSortEnd,
}: ChartSettingSeriesOrderProps) => {
const [isSeriesPickerVisible, setSeriesPickerVisible] = useState(false);
......@@ -64,9 +66,14 @@ export const ChartSettingSeriesOrder = ({
const handleSortEnd = useCallback(
({ id, newIndex }: DragEndEvent) => {
const oldIndex = orderedItems.findIndex(item => item.key === id);
onChange(arrayMove(orderedItems, oldIndex, newIndex));
if (onSortEnd != null) {
onSortEnd(arrayMove(orderedItems, oldIndex, newIndex));
} else {
onChange(arrayMove(orderedItems, oldIndex, newIndex));
}
},
[orderedItems, onChange],
[orderedItems, onChange, onSortEnd],
);
const getItemTitle = useCallback((item: SortableItem) => {
......
......@@ -28,6 +28,6 @@ export const SLICE_THRESHOLD = 0.025; // approx 1 degree in percentage
export const OTHER_SLICE_MIN_PERCENTAGE = 0.005;
export const OTHER_SLICE_KEY = t`Other`;
export const OTHER_SLICE_KEY = "___OTHER___";
export const TOTAL_TEXT = t`Total`.toUpperCase();
import { pie } from "d3";
import { t } from "ttag";
import _ from "underscore";
import { findWithIndex } from "metabase/lib/arrays";
......@@ -61,6 +62,7 @@ function getColDescs(
export function getPieChartModel(
rawSeries: RawSeries,
settings: ComputedVisualizationSettings,
hiddenSlices: Array<string | number> = [],
renderingContext: RenderingContext,
showWarning?: ShowWarning,
): PieChartModel {
......@@ -102,9 +104,9 @@ export function getPieChartModel(
throw Error("missing `pie.rows` setting");
}
const visiblePieRows = pieRows.filter(row => row.enabled && !row.hidden);
const enabledPieRows = pieRows.filter(row => row.enabled && !row.hidden);
const pieRowsWithValues = visiblePieRows.map(pieRow => {
const pieRowsWithValues = enabledPieRows.map(pieRow => {
const value = rowValuesByKey.get(pieRow.key);
if (value === undefined) {
throw Error(`No row values found for key ${pieRow.key}`);
......@@ -115,15 +117,20 @@ export function getPieChartModel(
value,
};
});
const visiblePieRows = pieRowsWithValues.filter(row =>
row.isOther
? !hiddenSlices.includes(OTHER_SLICE_KEY)
: !hiddenSlices.includes(row.key),
);
// We allow negative values if every single metric value is negative or 0
// (`isNonPositive` = true). If the values are mixed between positives and
// negatives, we'll simply ignore the negatives in all calculations.
const isNonPositive =
pieRowsWithValues.every(row => row.value <= 0) &&
!pieRowsWithValues.every(row => row.value === 0);
visiblePieRows.every(row => row.value <= 0) &&
!visiblePieRows.every(row => row.value === 0);
const total = pieRowsWithValues.reduce((currTotal, { value }) => {
const total = visiblePieRows.reduce((currTotal, { value }) => {
if (!isNonPositive && value < 0) {
showWarning?.(pieNegativesWarning().text);
return currTotal;
......@@ -134,14 +141,18 @@ export function getPieChartModel(
const [slices, others] = _.chain(pieRowsWithValues)
.map(({ value, color, key, name, isOther }): PieSliceData => {
const visible = isOther
? !hiddenSlices.includes(OTHER_SLICE_KEY)
: !hiddenSlices.includes(key);
return {
key,
name,
value: isNonPositive ? -1 * value : value,
displayValue: value,
normalizedPercentage: value / total, // slice percentage values are normalized to 0-1 scale
normalizedPercentage: visible ? value / total : 0, // slice percentage values are normalized to 0-1 scale
rowIndex: rowIndiciesByKey.get(key),
color,
visible,
isOther,
noHover: false,
includeInLegend: true,
......@@ -161,13 +172,15 @@ export function getPieChartModel(
// Only add "other" slice if there are slices below threshold with non-zero total
const otherTotal = others.reduce((currTotal, o) => currTotal + o.value, 0);
if (otherTotal > 0) {
const visible = !hiddenSlices.includes(OTHER_SLICE_KEY);
slices.push({
key: OTHER_SLICE_KEY,
name: OTHER_SLICE_KEY,
name: t`Other`,
value: otherTotal,
displayValue: otherTotal,
normalizedPercentage: otherTotal / total,
normalizedPercentage: visible ? otherTotal / total : 0,
color: renderingContext.getColor("text-light"),
visible,
isOther: true,
noHover: false,
includeInLegend: true,
......@@ -177,7 +190,10 @@ export function getPieChartModel(
slices.forEach(slice => {
// We increase the size of small slices, otherwise they will not be visible
// in echarts due to the border rendering over the tiny slice
if (slice.normalizedPercentage < OTHER_SLICE_MIN_PERCENTAGE) {
if (
slice.visible &&
slice.normalizedPercentage < OTHER_SLICE_MIN_PERCENTAGE
) {
slice.value = total * OTHER_SLICE_MIN_PERCENTAGE;
}
});
......@@ -186,11 +202,12 @@ export function getPieChartModel(
if (slices.length === 0) {
slices.push({
key: OTHER_SLICE_KEY,
name: OTHER_SLICE_KEY,
name: t`Other`,
value: 1,
displayValue: 0,
normalizedPercentage: 0,
color: renderingContext.getColor("text-light"),
visible: true,
isOther: true,
noHover: true,
includeInLegend: false,
......
......@@ -24,6 +24,7 @@ export interface PieSliceData {
value: number; // size of the slice used for rendering
displayValue: number; // real metric value of the slice displayed in tooltip or total graphic
normalizedPercentage: number;
visible: boolean;
color: string;
isOther: boolean;
noHover: boolean;
......
......@@ -205,52 +205,54 @@ export function getPieChartOption(
};
// Series data
const data = chartModel.slices.map(s => {
const labelColor = getTextColorForBackground(
s.data.color,
renderingContext.getColor,
);
const label = formatSlicePercent(s.data.key);
const isLabelVisible = getIsLabelVisible(
label,
s,
innerRadius,
outerRadius,
fontSize,
renderingContext,
);
const data = chartModel.slices
.filter(s => s.data.visible)
.map(s => {
const labelColor = getTextColorForBackground(
s.data.color,
renderingContext.getColor,
);
const label = formatSlicePercent(s.data.key);
const isLabelVisible = getIsLabelVisible(
label,
s,
innerRadius,
outerRadius,
fontSize,
renderingContext,
);
return {
value: s.data.value,
name: s.data.key,
itemStyle: { color: s.data.color },
label: {
color: labelColor,
formatter: () => (isLabelVisible ? label : " "),
},
emphasis: {
itemStyle: {
color: s.data.color,
borderColor: renderingContext.theme.pie.borderColor,
return {
value: s.data.value,
name: s.data.name,
itemStyle: { color: s.data.color },
label: {
color: labelColor,
formatter: () => (isLabelVisible ? label : " "),
},
},
blur: {
itemStyle: {
// We have to fade the slices through `color` rather than `opacity`
// becuase echarts' will apply the opacity to the white border,
// causing the underlying color to leak. It is safe to use non-hex
// values here, since this value will never be used in batik
// (there's no emphasis/blur for static viz).
color: Color(s.data.color).fade(0.7).rgb().string(),
opacity: 1,
emphasis: {
itemStyle: {
color: s.data.color,
borderColor: renderingContext.theme.pie.borderColor,
},
},
label: {
opacity:
labelColor === renderingContext.getColor("text-dark") ? 0.3 : 1,
blur: {
itemStyle: {
// We have to fade the slices through `color` rather than `opacity`
// becuase echarts' will apply the opacity to the white border,
// causing the underlying color to leak. It is safe to use non-hex
// values here, since this value will never be used in batik
// (there's no emphasis/blur for static viz).
color: Color(s.data.color).fade(0.7).rgb().string(),
opacity: 1,
},
label: {
opacity:
labelColor === renderingContext.getColor("text-dark") ? 0.3 : 1,
},
},
},
};
});
};
});
return {
// Unlike the cartesian chart, `animationDuration: 0` does not prevent the
......
import type { EChartsType } from "echarts/core";
import { useCallback, useMemo, useRef, useState } from "react";
import { type MouseEvent, useCallback, useMemo, useRef, useState } from "react";
import { useSet } from "react-use";
import { isNotNull } from "metabase/lib/types";
import ChartWithLegend from "metabase/visualizations/components/ChartWithLegend";
import { getPieChartFormatters } from "metabase/visualizations/echarts/pie/format";
import { getPieChartModel } from "metabase/visualizations/echarts/pie/model";
......@@ -34,6 +36,10 @@ export function PieChart(props: VisualizationProps) {
const chartRef = useRef<EChartsType>();
const [sideLength, setSideLength] = useState(0);
const [hiddenSlices, { toggle: toggleSliceVisibility }] = useSet<
string | number
>();
const showWarning = useCallback(
(warning: string) => onRender({ warnings: [warning] }),
[onRender],
......@@ -45,8 +51,15 @@ export function PieChart(props: VisualizationProps) {
isFullscreen,
});
const chartModel = useMemo(
() => getPieChartModel(rawSeries, settings, renderingContext, showWarning),
[rawSeries, settings, renderingContext, showWarning],
() =>
getPieChartModel(
rawSeries,
settings,
Array.from(hiddenSlices),
renderingContext,
showWarning,
),
[rawSeries, settings, hiddenSlices, renderingContext, showWarning],
);
const formatters = useMemo(
() => getPieChartFormatters(chartModel, settings, renderingContext),
......@@ -90,7 +103,12 @@ export function PieChart(props: VisualizationProps) {
const legendTitles = chartModel.slices
.filter(s => s.data.includeInLegend)
.map(s => {
const label = s.data.isOther ? s.data.key : s.data.name;
const label = s.data.name;
// Hidden slices don't have a percentage
if (s.data.normalizedPercentage === 0) {
return label;
}
const percent =
settings["pie.percent_visibility"] === "legend" ||
......@@ -101,6 +119,11 @@ export function PieChart(props: VisualizationProps) {
return [label, percent];
});
const hiddenSlicesLegendIndices = chartModel.slices
.filter(s => s.data.includeInLegend)
.map((s, index) => (hiddenSlices.has(s.data.key) ? index : null))
.filter(isNotNull);
const legendColors = chartModel.slices
.filter(s => s.data.includeInLegend)
.map(s => s.data.color);
......@@ -114,11 +137,25 @@ export function PieChart(props: VisualizationProps) {
},
);
const handleToggleSeriesVisibility = (
event: MouseEvent,
sliceIndex: number,
) => {
const slice = chartModel.slices[sliceIndex];
const willShowSlice = hiddenSlices.has(slice.data.key);
const hasMoreVisibleSlices =
chartModel.slices.length - hiddenSlices.size > 1;
if (hasMoreVisibleSlices || willShowSlice) {
toggleSliceVisibility(slice.data.key);
}
};
useCloseTooltipOnScroll(chartRef);
return (
<ChartWithLegend
legendTitles={legendTitles}
legendHiddenIndices={hiddenSlicesLegendIndices}
legendColors={legendColors}
showLegend={showLegend}
onHoverChange={onHoverChange}
......@@ -126,6 +163,7 @@ export function PieChart(props: VisualizationProps) {
gridSize={props.gridSize}
hovered={props.hovered}
isDashboard={isDashboard}
onToggleSeriesVisibility={handleToggleSeriesVisibility}
>
<ChartRenderer
ref={containerRef}
......
......@@ -2,7 +2,7 @@ import { t } from "ttag";
import _ from "underscore";
import { formatValue } from "metabase/lib/formatting";
import { ChartSettingOrderedSimple } from "metabase/visualizations/components/settings/ChartSettingOrderedSimple";
import { ChartSettingSeriesOrder } from "metabase/visualizations/components/settings/ChartSettingSeriesOrder";
import type { PieRow } from "metabase/visualizations/echarts/pie/model/types";
import {
ChartSettingsError,
......@@ -92,7 +92,7 @@ export const PIE_CHART_DEFINITION: VisualizationDefinition = {
}),
"pie.rows": {
section: t`Data`,
widget: ChartSettingOrderedSimple,
widget: ChartSettingSeriesOrder,
getHidden: (_rawSeries, settings) => settings["pie.dimension"] == null,
getValue: (rawSeries, settings) => {
return getPieRows(rawSeries, settings, (value, options) =>
......
......@@ -31,19 +31,21 @@ export const getTooltipModel = (
chartModel: PieChartModel,
formatters: PieChartFormatters,
): EChartsTooltipModel => {
const hoveredIndex = dataIndexToHoveredIndex(dataIndex);
const hoveredIndex = dataIndexToHoveredIndex(dataIndex, chartModel);
const hoveredOther =
chartModel.slices[hoveredIndex].data.isOther &&
chartModel.otherSlices.length > 1;
const rows = (hoveredOther ? chartModel.otherSlices : chartModel.slices).map(
slice => ({
name: slice.data.name,
value: slice.data.displayValue,
color: hoveredOther ? undefined : slice.data.color,
formatter: formatters.formatMetric,
}),
);
const slices = hoveredOther
? chartModel.otherSlices
: chartModel.slices.filter(slice => slice.data.visible);
const rows = slices.map(slice => ({
name: slice.data.name,
value: slice.data.displayValue,
color: hoveredOther ? undefined : slice.data.color,
formatter: formatters.formatMetric,
}));
const rowsTotal = getTotalValue(rows);
const isShowingTotalSensible = rows.length > 1;
......@@ -53,7 +55,7 @@ export const getTooltipModel = (
? getMarkerColorClass(row.color)
: undefined;
return {
isFocused: !hoveredOther && index === hoveredIndex,
isFocused: !hoveredOther && index === dataIndex - 1,
markerColorClass,
name: row.name,
values: [
......@@ -78,8 +80,21 @@ export const getTooltipModel = (
};
};
const dataIndexToHoveredIndex = (index: number) => index - 1;
const hoveredIndexToDataIndex = (index: number) => index + 1;
const dataIndexToHoveredIndex = (index: number, chartModel: PieChartModel) => {
const visibleSlices = chartModel.slices.filter(slice => slice.data.visible);
const slice = visibleSlices[index - 1];
const innerIndex = chartModel.slices.findIndex(
s => s.data.key === slice.data.key && s.data.isOther === slice.data.isOther,
);
return innerIndex;
};
const hoveredIndexToDataIndex = (index: number, chartModel: PieChartModel) => {
const baseIndex = index + 1;
const slicesBefore = chartModel.slices.slice(0, index);
const hiddenSlicesBefore = slicesBefore.filter(slice => !slice.data.visible);
return baseIndex - hiddenSlicesBefore.length;
};
function getHoverData(
event: EChartsSeriesMouseEvent,
......@@ -88,7 +103,7 @@ function getHoverData(
if (event.dataIndex == null) {
return null;
}
const index = dataIndexToHoveredIndex(event.dataIndex);
const index = dataIndexToHoveredIndex(event.dataIndex, chartModel);
const indexOutOfBounds = chartModel.slices[index] == null;
if (indexOutOfBounds || chartModel.slices[index].data.noHover) {
......@@ -112,7 +127,8 @@ function handleClick(
if (!event.dataIndex) {
return;
}
const slice = chartModel.slices[dataIndexToHoveredIndex(event.dataIndex)];
const index = dataIndexToHoveredIndex(event.dataIndex, chartModel);
const slice = chartModel.slices[index];
const data =
slice.data.rowIndex != null
? dataProp.rows[slice.data.rowIndex].map((value, index) => ({
......@@ -163,19 +179,19 @@ export function useChartEvents(
chart.dispatchAction({
type: "highlight",
dataIndex: hoveredIndexToDataIndex(hoveredIndex),
dataIndex: hoveredIndexToDataIndex(hoveredIndex, chartModel),
seriesIndex: 0,
});
return () => {
chart.dispatchAction({
type: "downplay",
dataIndex: hoveredIndexToDataIndex(hoveredIndex),
dataIndex: hoveredIndexToDataIndex(hoveredIndex, chartModel),
seriesIndex: 0,
});
};
},
[chart, hoveredIndex],
[chart, chartModel, hoveredIndex],
);
useClickedStateTooltipSync(chartRef.current, props.clicked);
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment