Skip to content
Snippets Groups Projects
Unverified Commit 0db44038 authored by github-automation-metabase's avatar github-automation-metabase Committed by GitHub
Browse files

add percentages to sankey tooltip (#51193) (#51464)


* add percentages to sankey tooltip

* another tooltip variant

* specs

* lint

Co-authored-by: default avatarAleksandr Lesnenko <alxnddr@users.noreply.github.com>
parent b926fa2e
No related branches found
No related tags found
No related merge requests found
Showing
with 270 additions and 149 deletions
......@@ -85,27 +85,48 @@ describe("scenarios > visualizations > sankey", () => {
// Ensure it shows compact labels
H.echartsContainer().findByText("60.0k");
// Ensure tooltip shows correct values
H.sankeyEdge("#81898e").eq(0).realHover();
// Ensure tooltip shows correct values for edges
H.sankeyEdge("#81898e").eq(8).realHover();
H.assertEChartsTooltip({
header: "Social Media → Landing Page",
header: "Onboarding → Active Users",
rows: [
{
name: "METRIC",
value: "30,000",
color: "#F7C41F",
name: "Active Users",
value: "25,000",
secondaryValue: "83.33 %",
},
{
color: "#F2A86F",
name: "Churned After Onboarding",
value: "5,000",
secondaryValue: "16.67 %",
},
],
footer: { name: "Total", value: "30,000", secondaryValue: "100 %" },
});
H.chartPathWithFillColor("#509EE3").realHover();
// Ensure tooltip shows correct values for nodes
H.chartPathWithFillColor("#E75454").realHover();
H.assertEChartsTooltip({
header: "Social Media",
header: "Onboarding",
rows: [
{
name: "METRIC",
value: "30,000",
color: "#F7C41F",
name: "Active Users",
value: "25,000",
secondaryValue: "83.33 %",
},
{
color: "#F2A86F",
name: "Churned After Onboarding",
value: "5,000",
secondaryValue: "16.67 %",
},
],
footer: { name: "Total", value: "30,000", secondaryValue: "100 %" },
blurAfter: true,
});
// Ensure saving the question works
......
......@@ -9,6 +9,7 @@
.Table {
min-width: 240px;
width: 100%;
border-collapse: collapse;
margin: 0.25rem 0 0.875rem 0;
}
......
......@@ -75,6 +75,7 @@ export const getSankeyData = (
hasOutputs: false,
inputColumnValues: {},
outputColumnValues: {},
outputLinkByTarget: new Map<RowValue, SankeyLink>(),
};
node.level = Math.max(node.level, level);
......@@ -117,19 +118,22 @@ export const getSankeyData = (
const source = row[sankeyColumns.source.index];
const target = row[sankeyColumns.target.index];
const value = row[sankeyColumns.value.index];
const sourceInfo = updateNode(source, 0, "source", row);
updateNode(target, sourceInfo.level + 1, "target", row);
const linkKey = `${NULL_CHAR}${source}->${target}`;
const sourceNode = updateNode(source, 0, "source", row);
const targetNode = updateNode(target, sourceNode.level + 1, "target", row);
const link: SankeyLink = linkMap.get(linkKey) ?? {
sourceNode,
targetNode,
source,
target,
value: 0,
columnValues: {},
};
sourceNode.outputLinkByTarget.set(target, link);
link.value = sumMetric(link.value, value);
cols.forEach((_column, index) => {
const columnKey = columnInfos[index].key;
......
......@@ -115,88 +115,111 @@ describe("getSankeyData", () => {
const result = getSankeyData(rawSeries, sankeyColumns);
expect(result).toEqual({
nodes: [
{
rawName: "A",
level: 0,
hasInputs: false,
hasOutputs: true,
inputColumnValues: {},
outputColumnValues: {
[getColumnKey(columns[0])]: "A",
[getColumnKey(columns[1])]: "C",
[getColumnKey(columns[2])]: 13,
[getColumnKey(columns[3])]: 130,
},
},
{
rawName: "B",
level: 1,
hasInputs: true,
hasOutputs: true,
outputColumnValues: {
[getColumnKey(columns[0])]: "B",
[getColumnKey(columns[1])]: "C",
[getColumnKey(columns[2])]: 20,
[getColumnKey(columns[3])]: 200,
},
inputColumnValues: {
[getColumnKey(columns[0])]: "A",
[getColumnKey(columns[1])]: "B",
[getColumnKey(columns[2])]: 11,
[getColumnKey(columns[3])]: 110,
},
},
{
rawName: "C",
level: 2,
hasInputs: true,
hasOutputs: false,
inputColumnValues: {
[getColumnKey(columns[0])]: "A",
[getColumnKey(columns[1])]: "C",
[getColumnKey(columns[2])]: 22,
[getColumnKey(columns[3])]: 220,
},
outputColumnValues: {},
},
],
links: [
{
source: "A",
target: "B",
value: 11,
columnValues: {
[getColumnKey(columns[0])]: "A",
[getColumnKey(columns[1])]: "B",
[getColumnKey(columns[2])]: 11,
[getColumnKey(columns[3])]: 110,
},
},
{
source: "B",
target: "C",
value: 20,
columnValues: {
[getColumnKey(columns[0])]: "B",
[getColumnKey(columns[1])]: "C",
[getColumnKey(columns[2])]: 20,
[getColumnKey(columns[3])]: 200,
},
},
{
source: "A",
target: "C",
value: 2,
columnValues: {
[getColumnKey(columns[0])]: "A",
[getColumnKey(columns[1])]: "C",
[getColumnKey(columns[2])]: 2,
[getColumnKey(columns[3])]: 20,
},
},
],
// Verify nodes
expect(result.nodes).toHaveLength(3);
// Node A
const nodeA = result.nodes[0];
expect(nodeA).toMatchObject({
rawName: "A",
level: 0,
hasInputs: false,
hasOutputs: true,
inputColumnValues: {},
outputColumnValues: {
[getColumnKey(columns[0])]: "A",
[getColumnKey(columns[1])]: "C",
[getColumnKey(columns[2])]: 13,
[getColumnKey(columns[3])]: 130,
},
});
expect(nodeA.outputLinkByTarget.size).toBe(2);
// Node B
const nodeB = result.nodes[1];
expect(nodeB).toMatchObject({
rawName: "B",
level: 1,
hasInputs: true,
hasOutputs: true,
inputColumnValues: {
[getColumnKey(columns[0])]: "A",
[getColumnKey(columns[1])]: "B",
[getColumnKey(columns[2])]: 11,
[getColumnKey(columns[3])]: 110,
},
outputColumnValues: {
[getColumnKey(columns[0])]: "B",
[getColumnKey(columns[1])]: "C",
[getColumnKey(columns[2])]: 20,
[getColumnKey(columns[3])]: 200,
},
});
expect(nodeB.outputLinkByTarget.size).toBe(1);
// Node C
const nodeC = result.nodes[2];
expect(nodeC).toMatchObject({
rawName: "C",
level: 2,
hasInputs: true,
hasOutputs: false,
inputColumnValues: {
[getColumnKey(columns[0])]: "A",
[getColumnKey(columns[1])]: "C",
[getColumnKey(columns[2])]: 22,
[getColumnKey(columns[3])]: 220,
},
outputColumnValues: {},
});
expect(nodeC.outputLinkByTarget.size).toBe(0);
// Verify links
expect(result.links).toHaveLength(3);
// Link A->B
expect(result.links[0]).toMatchObject({
source: "A",
target: "B",
value: 11,
columnValues: {
[getColumnKey(columns[0])]: "A",
[getColumnKey(columns[1])]: "B",
[getColumnKey(columns[2])]: 11,
[getColumnKey(columns[3])]: 110,
},
});
expect(result.links[0].sourceNode).toBe(nodeA);
expect(result.links[0].targetNode).toBe(nodeB);
// Link B->C
expect(result.links[1]).toMatchObject({
source: "B",
target: "C",
value: 20,
columnValues: {
[getColumnKey(columns[0])]: "B",
[getColumnKey(columns[1])]: "C",
[getColumnKey(columns[2])]: 20,
[getColumnKey(columns[3])]: 200,
},
});
expect(result.links[1].sourceNode).toBe(nodeB);
expect(result.links[1].targetNode).toBe(nodeC);
// Link A->C
expect(result.links[2]).toMatchObject({
source: "A",
target: "C",
value: 2,
columnValues: {
[getColumnKey(columns[0])]: "A",
[getColumnKey(columns[1])]: "C",
[getColumnKey(columns[2])]: 2,
[getColumnKey(columns[3])]: 20,
},
});
expect(result.links[2].sourceNode).toBe(nodeA);
expect(result.links[2].targetNode).toBe(nodeC);
});
});
......@@ -16,6 +16,7 @@ export interface SankeyNode {
hasOutputs: boolean;
inputColumnValues: Record<ColumnKey, RowValue>;
outputColumnValues: Record<ColumnKey, RowValue>;
outputLinkByTarget: Map<RowValue, SankeyLink>;
}
export interface SankeyData {
......@@ -24,6 +25,8 @@ export interface SankeyData {
}
export interface SankeyLink {
sourceNode: SankeyNode;
targetNode: SankeyNode;
source: RowValue;
target: RowValue;
value: RowValue;
......
import type { TooltipOption } from "echarts/types/dist/shared";
import { renderToString } from "react-dom/server";
import { t } from "ttag";
import { EChartsTooltip } from "metabase/visualizations/components/ChartTooltip/EChartsTooltip";
import { getTooltipBaseOption } from "metabase/visualizations/echarts/tooltip";
import { formatPercent } from "metabase/static-viz/lib/numbers";
import {
EChartsTooltip,
type EChartsTooltipRow,
} from "metabase/visualizations/components/ChartTooltip/EChartsTooltip";
import { getPercent } from "metabase/visualizations/components/ChartTooltip/StackedDataTooltip/utils";
import {
getMarkerColorClass,
getTooltipBaseOption,
} from "metabase/visualizations/echarts/tooltip";
import { getNumberOr } from "metabase/visualizations/lib/settings/row-values";
import { getColumnKey } from "metabase-lib/v1/queries/utils/column-key";
import type { DatasetColumn } from "metabase-types/api";
import type { SankeyFormatters } from "../model/types";
import type { SankeyChartModel } from "../model/types";
interface ChartItemTooltipProps {
metricColumnKey: string;
metricColumnName: string;
formatters: SankeyFormatters;
chartModel: SankeyChartModel;
params: any;
}
const ChartItemTooltip = ({
metricColumnKey,
metricColumnName,
formatters,
params,
}: ChartItemTooltipProps) => {
const ChartItemTooltip = ({ chartModel, params }: ChartItemTooltipProps) => {
const valueColumn = chartModel.sankeyColumns.value.column;
const { formatters } = chartModel;
const valueColumnKey = getColumnKey(valueColumn);
const data = params.data;
let header = "";
let value = null;
if (params.dataType === "edge") {
let node = null;
let rows: EChartsTooltipRow[] = [];
let footer = undefined;
if (params.dataType === "node") {
node = chartModel.data.nodes.find(node => node.rawName === data.rawName)!;
header = formatters.node(node);
} else if (params.dataType === "edge") {
node = chartModel.data.nodes.find(node => node.rawName === data.source)!;
header = `${formatters.source(data.source)}${formatters.target(data.target)}`;
value = params.value;
} else if (params.dataType === "node") {
header = formatters.node(data);
value = Math.max(
data.inputColumnValues[metricColumnKey] ?? 0,
data.outputColumnValues[metricColumnKey] ?? 0,
);
}
return (
<EChartsTooltip
header={header}
rows={[
{
name: metricColumnName,
values: [formatters.value(value)],
},
]}
/>
if (!node) {
console.warn(`Node has not been found ${JSON.stringify(params)}`);
return null;
}
const nodeValue = Math.max(
getNumberOr(node.inputColumnValues[valueColumnKey], 0),
getNumberOr(node.outputColumnValues[valueColumnKey], 0),
);
const formattedNodeValue = formatters.value(nodeValue);
rows = Array.from(node.outputLinkByTarget.values()).map(link => {
const color = chartModel.nodeColors[String(link.targetNode.rawName)];
const isFocused = params.dataType === "edge" && data.target === link.target;
return {
isFocused,
name: formatters.target(link.target),
values: [
formatters.value(link.value),
formatPercent(getPercent(nodeValue, link.value) ?? 0),
],
markerColorClass: getMarkerColorClass(color),
};
});
const isEndNode = rows.length === 0;
if (isEndNode) {
rows = [
{
name: formatters.target(node.rawName),
markerColorClass: getMarkerColorClass(
chartModel.nodeColors[String(node.rawName)],
),
values: [formattedNodeValue],
},
];
} else {
footer = {
name: t`Total`,
values: [formattedNodeValue, formatPercent(1)],
};
}
return <EChartsTooltip header={header} rows={rows} footer={footer} />;
};
export const getTooltipOption = (
containerRef: React.RefObject<HTMLDivElement>,
metricColumn: DatasetColumn,
formatters: SankeyFormatters,
chartModel: SankeyChartModel,
): TooltipOption => {
const metricColumnName = metricColumn.display_name;
const metricColumnKey = getColumnKey(metricColumn);
return {
...getTooltipBaseOption(containerRef),
trigger: "item",
......@@ -66,12 +102,7 @@ export const getTooltipOption = (
}
return renderToString(
<ChartItemTooltip
params={params}
metricColumnName={metricColumnName}
metricColumnKey={metricColumnKey}
formatters={formatters}
/>,
<ChartItemTooltip params={params} chartModel={chartModel} />,
);
},
};
......
......@@ -3,12 +3,14 @@ import type React from "react";
import { useEffect, useMemo } from "react";
import _ from "underscore";
import { getObjectValues } from "metabase/lib/objects";
import { isNotNull } from "metabase/lib/types";
import TooltipStyles from "metabase/visualizations/components/ChartTooltip/EChartsTooltip/EChartsTooltip.module.css";
import type { ComputedVisualizationSettings } from "metabase/visualizations/types";
import type { ClickObject } from "metabase-lib";
import type { BaseCartesianChartModel } from "../cartesian/model/types";
import type { SankeyChartModel } from "../graph/sankey/model/types";
import type { PieChartModel, SliceTreeNode } from "../pie/model/types";
import { getArrayFromMapValues } from "../pie/util";
......@@ -151,6 +153,14 @@ export const useCartesianChartSeriesColorsClasses = (
return useInjectSeriesColorsClasses(hexColors);
};
export const useSankeyChartColorsClasses = (chartModel: SankeyChartModel) => {
const hexColors = useMemo(() => {
return getObjectValues(chartModel.nodeColors).filter(isNotNull);
}, [chartModel]);
return useInjectSeriesColorsClasses(hexColors);
};
function getColorsFromSlices(slices: SliceTreeNode[]) {
const colors = slices.map(s => s.color);
slices.forEach(s =>
......
......@@ -8,7 +8,10 @@ import { getSankeyLayout } from "metabase/visualizations/echarts/graph/sankey/la
import { getSankeyChartModel } from "metabase/visualizations/echarts/graph/sankey/model";
import { getSankeyChartOption } from "metabase/visualizations/echarts/graph/sankey/option";
import { getTooltipOption } from "metabase/visualizations/echarts/graph/sankey/option/tooltip";
import { useCloseTooltipOnScroll } from "metabase/visualizations/echarts/tooltip";
import {
useCloseTooltipOnScroll,
useSankeyChartColorsClasses,
} from "metabase/visualizations/echarts/tooltip";
import { useBrowserRenderingContext } from "metabase/visualizations/hooks/use-browser-rendering-context";
import type { VisualizationProps } from "metabase/visualizations/types";
......@@ -44,11 +47,7 @@ export const SankeyChart = ({
const option = useMemo(
() => ({
...getSankeyChartOption(chartModel, layout, settings, renderingContext),
tooltip: getTooltipOption(
containerRef,
chartModel.sankeyColumns.value.column,
chartModel.formatters,
),
tooltip: getTooltipOption(containerRef, chartModel),
}),
[chartModel, layout, settings, renderingContext],
);
......@@ -68,13 +67,18 @@ export const SankeyChart = ({
useCloseTooltipOnScroll(chartRef);
const sankeyColorsCss = useSankeyChartColorsClasses(chartModel);
return (
<ResponsiveEChartsRenderer
ref={containerRef}
option={option}
eventHandlers={eventHandlers}
onInit={handleInit}
/>
<>
<ResponsiveEChartsRenderer
ref={containerRef}
option={option}
eventHandlers={eventHandlers}
onInit={handleInit}
/>
{sankeyColorsCss}
</>
);
};
......
......@@ -65,6 +65,7 @@ describe("createSankeyClickData", () => {
[getColumnKey(columns[2])]: 10,
},
outputColumnValues: {},
outputLinkByTarget: new Map(),
},
event: mockEvent,
value: "A",
......@@ -105,6 +106,7 @@ describe("createSankeyClickData", () => {
[getColumnKey(columns[2])]: 10,
},
outputColumnValues: {},
outputLinkByTarget: new Map(),
},
event: mockEvent,
value: "B",
......@@ -131,6 +133,26 @@ describe("createSankeyClickData", () => {
});
it("should create click data for edge events", () => {
const sourceNode = {
rawName: "A",
level: 0,
hasInputs: false,
hasOutputs: true,
inputColumnValues: {},
outputColumnValues: {},
outputLinkByTarget: new Map(),
};
const targetNode = {
rawName: "B",
level: 1,
hasInputs: true,
hasOutputs: false,
inputColumnValues: {},
outputColumnValues: {},
outputLinkByTarget: new Map(),
};
const edgeEvent = {
dataType: "edge",
data: {
......@@ -142,6 +164,8 @@ describe("createSankeyClickData", () => {
[getColumnKey(columns[1])]: "B",
[getColumnKey(columns[2])]: 10,
},
sourceNode,
targetNode,
},
event: mockEvent,
value: 10,
......
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