diff --git a/frontend/src/metabase/internal/pages/StaticVizPage.jsx b/frontend/src/metabase/internal/pages/StaticVizPage.jsx index f2fd3be71383ccd6c80357eb71439b8f6eb3aaee..479a244ce12cc952ee031bf320732d516f43f3c2 100644 --- a/frontend/src/metabase/internal/pages/StaticVizPage.jsx +++ b/frontend/src/metabase/internal/pages/StaticVizPage.jsx @@ -20,12 +20,9 @@ import { } from "../../static-viz/components/ProgressBar/constants"; import { TIME_SERIES_WATERFALL_CHART_DEFAULT_OPTIONS, - TIME_SERIES_WATERFALL_CHART_TYPE, -} from "../../static-viz/components/TimeSeriesWaterfallChart/constants"; -import { CATEGORICAL_WATERFALL_CHART_DEFAULT_OPTIONS, - CATEGORICAL_WATERFALL_CHART_TYPE, -} from "../../static-viz/components/CategoricalWaterfallChart/constants"; + WATERFALL_CHART_TYPE, +} from "../../static-viz/components/WaterfallChart/constants"; import { FUNNEL_CHART_DEFAULT_OPTIONS, FUNNEL_CHART_TYPE, @@ -160,14 +157,17 @@ export default function StaticVizPage() { <PageSection> <Subhead>Waterfall chart with timeseries data and no total</Subhead> <StaticChart - type={TIME_SERIES_WATERFALL_CHART_TYPE} + type={WATERFALL_CHART_TYPE} options={TIME_SERIES_WATERFALL_CHART_DEFAULT_OPTIONS} /> </PageSection> <PageSection> - <Subhead>Waterfall chart with categorical data and total</Subhead> + <Subhead> + Waterfall chart with categorical data and total (rotated X-axis tick + labels) + </Subhead> <StaticChart - type={CATEGORICAL_WATERFALL_CHART_TYPE} + type={WATERFALL_CHART_TYPE} options={CATEGORICAL_WATERFALL_CHART_DEFAULT_OPTIONS} /> </PageSection> diff --git a/frontend/src/metabase/static-viz/components/CategoricalWaterfallChart/CategoricalWaterfallChart.jsx b/frontend/src/metabase/static-viz/components/CategoricalWaterfallChart/CategoricalWaterfallChart.jsx deleted file mode 100644 index 527be39e3f13055ea97d3fda03c3fa3f6ec4ccae..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/static-viz/components/CategoricalWaterfallChart/CategoricalWaterfallChart.jsx +++ /dev/null @@ -1,181 +0,0 @@ -import React from "react"; -import PropTypes from "prop-types"; -import { AxisBottom, AxisLeft } from "@visx/axis"; -import { GridRows } from "@visx/grid"; -import { scaleBand, scaleLinear } from "@visx/scale"; -import { Bar } from "@visx/shape"; -import { Group } from "@visx/group"; -import { Text } from "@visx/text"; -import { truncateText } from "metabase/static-viz/lib/text"; -import { - getLabelProps, - getXTickLabelProps, - getYTickLabelProps, - getYTickWidth, - getXTickWidth, - getRotatedXTickHeight, -} from "metabase/static-viz/lib/axes"; -import { formatNumber } from "metabase/static-viz/lib/numbers"; -import { - calculateWaterfallDomain, - calculateWaterfallEntries, - getWaterfallEntryColor, -} from "metabase/static-viz/lib/waterfall"; -import { POSITIONAL_ACCESSORS } from "../../constants/accessors"; -import { getWaterfallColors } from "../../lib/colors"; - -const propTypes = { - data: PropTypes.array.isRequired, - accessors: PropTypes.shape({ - x: PropTypes.func.isRequired, - y: PropTypes.func.isRequired, - }), - settings: PropTypes.shape({ - x: PropTypes.object, - y: PropTypes.object, - colors: PropTypes.object, - showTotal: PropTypes.bool, - }), - labels: PropTypes.shape({ - left: PropTypes.string, - bottom: PropTypes.string, - }), - getColor: PropTypes.func, -}; - -const layout = { - width: 540, - height: 300, - margin: { - top: 0, - left: 55, - right: 40, - bottom: 40, - }, - font: { - size: 11, - family: "Lato, sans-serif", - }, - barPadding: 0.2, - labelFontWeight: 700, - labelPadding: 12, - strokeDasharray: "4", - maxTickWidth: 100, -}; - -const CategoricalWaterfallChart = ({ - data, - accessors = POSITIONAL_ACCESSORS, - settings, - labels, - getColor, -}) => { - const entries = calculateWaterfallEntries( - data, - accessors, - settings?.showTotal, - ); - const isVertical = entries.length > 10; - const xTickWidth = getXTickWidth( - data, - accessors, - layout.maxTickWidth, - layout.font.size, - ); - const xTickHeight = getRotatedXTickHeight(xTickWidth); - const yTickWidth = getYTickWidth(data, accessors, settings, layout.font.size); - const xLabelOffset = xTickHeight + layout.labelPadding + layout.font.size; - const yLabelOffset = yTickWidth + layout.labelPadding; - const xMin = yLabelOffset + layout.font.size * 1.5; - const xMax = layout.width - layout.margin.right - layout.margin.left; - const yMin = isVertical ? xLabelOffset : layout.margin.bottom; - const yMax = layout.height - yMin - layout.margin.top; - const innerWidth = xMax - xMin; - const textBaseline = Math.floor(layout.font.size / 2); - const leftLabel = labels?.left; - const bottomLabel = !isVertical ? labels?.bottom : undefined; - - const xScale = scaleBand({ - domain: entries.map(entry => entry.x), - range: [0, xMax], - padding: layout.barPadding, - }); - - const yScale = scaleLinear({ - domain: calculateWaterfallDomain(entries), - range: [yMax, 0], - }); - - const getBarProps = entry => { - const width = xScale.bandwidth(); - - const height = Math.abs(yScale(entry.start) - yScale(entry.end)); - const x = xScale(entry.x); - const y = yScale(Math.max(entry.start, entry.end)); - - const fill = getWaterfallEntryColor( - entry, - getWaterfallColors(settings?.colors, getColor), - ); - - return { x, y, width, height, fill }; - }; - - const getXTickProps = ({ x, y, formattedValue, ...props }) => { - const textWidth = isVertical ? xTickWidth : xScale.bandwidth(); - const truncatedText = truncateText( - formattedValue, - textWidth, - layout.font.size, - ); - const transform = isVertical - ? `rotate(45, ${x} ${y}) translate(-${textBaseline} 0)` - : undefined; - - return { ...props, x, y, transform, children: truncatedText }; - }; - - return ( - <svg width={layout.width} height={layout.height}> - <Group top={layout.margin.top} left={xMin}> - <GridRows - scale={yScale} - width={innerWidth} - strokeDasharray={layout.strokeDasharray} - /> - {entries.map((entry, index) => ( - <Bar key={index} {...getBarProps(entry)} /> - ))} - </Group> - <AxisLeft - scale={yScale} - top={layout.margin.top} - left={xMin} - label={leftLabel} - labelOffset={yLabelOffset} - hideTicks - hideAxisLine - labelProps={getLabelProps(layout, getColor)} - tickFormat={value => formatNumber(value, settings?.y)} - tickLabelProps={() => getYTickLabelProps(layout, getColor)} - /> - - <AxisBottom - scale={xScale} - left={xMin} - top={yMax + layout.margin.top} - label={bottomLabel} - numTicks={entries.length} - stroke={getColor("text-light")} - tickStroke={getColor("text-light")} - labelProps={getLabelProps(layout, getColor)} - tickComponent={props => <Text {...getXTickProps(props)} />} - tickLabelProps={() => getXTickLabelProps(layout, isVertical, getColor)} - /> - </svg> - ); -}; - -CategoricalWaterfallChart.propTypes = propTypes; - -export default CategoricalWaterfallChart; diff --git a/frontend/src/metabase/static-viz/components/CategoricalWaterfallChart/constants.ts b/frontend/src/metabase/static-viz/components/CategoricalWaterfallChart/constants.ts deleted file mode 100644 index fbcfe1b2a6fab47f93028b55d660d7990759cd7b..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/static-viz/components/CategoricalWaterfallChart/constants.ts +++ /dev/null @@ -1,23 +0,0 @@ -export const CATEGORICAL_WATERFALL_CHART_TYPE = "categorical/waterfall"; - -export const CATEGORICAL_WATERFALL_CHART_DEFAULT_OPTIONS = { - data: [ - ["Stage 1", 800], - ["Stage 2", 400], - ["Stage 3", -300], - ["Stage 4", -100], - ["Stage 5", -50], - ["Stage 6", 200], - ["Stage 7", -100], - ["Stage 8", 300], - ["Stage 9", 100], - ["Stage 10", -300], - ], - settings: { - showTotal: true, - }, - labels: { - left: "Count", - bottom: "Created At", - }, -}; diff --git a/frontend/src/metabase/static-viz/components/CategoricalWaterfallChart/index.js b/frontend/src/metabase/static-viz/components/CategoricalWaterfallChart/index.js deleted file mode 100644 index 6a39b446ff7865d30a7a20626627cfd1a9e0c359..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/static-viz/components/CategoricalWaterfallChart/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./CategoricalWaterfallChart"; diff --git a/frontend/src/metabase/static-viz/components/TimeSeriesWaterfallChart/TimeSeriesWaterfallChart.jsx b/frontend/src/metabase/static-viz/components/TimeSeriesWaterfallChart/TimeSeriesWaterfallChart.jsx deleted file mode 100644 index 1b9573821505b456b5bbc81bdc593980c8eaa534..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/static-viz/components/TimeSeriesWaterfallChart/TimeSeriesWaterfallChart.jsx +++ /dev/null @@ -1,154 +0,0 @@ -import React from "react"; -import PropTypes from "prop-types"; -import { AxisBottom, AxisLeft } from "@visx/axis"; -import { GridRows } from "@visx/grid"; -import { scaleBand, scaleLinear } from "@visx/scale"; -import { Bar } from "@visx/shape"; -import { Group } from "@visx/group"; -import { - getLabelProps, - getXTickLabelProps, - getYTickLabelProps, - getYTickWidth, -} from "metabase/static-viz/lib/axes"; -import { formatNumber } from "metabase/static-viz/lib/numbers"; -import { - calculateWaterfallDomain, - calculateWaterfallEntries, - formatTimescaleWaterfallTick, - getWaterfallEntryColor, -} from "metabase/static-viz/lib/waterfall"; -import { sortTimeSeries } from "../../lib/sort"; -import { DATE_ACCESSORS } from "../../constants/accessors"; -import { getWaterfallColors } from "../../lib/colors"; - -const propTypes = { - data: PropTypes.array.isRequired, - accessors: PropTypes.shape({ - x: PropTypes.func.isRequired, - y: PropTypes.func.isRequired, - }), - settings: PropTypes.shape({ - x: PropTypes.object, - y: PropTypes.object, - colors: PropTypes.object, - showTotal: PropTypes.bool, - }), - labels: PropTypes.shape({ - left: PropTypes.string, - bottom: PropTypes.string, - }), - getColor: PropTypes.func, -}; - -const layout = { - width: 540, - height: 300, - margin: { - top: 0, - left: 55, - right: 40, - bottom: 40, - }, - font: { - size: 11, - family: "Lato, sans-serif", - }, - numTicks: 4, - barPadding: 0.2, - labelFontWeight: 700, - labelPadding: 12, - strokeDasharray: "4", -}; - -const TimeSeriesWaterfallChart = ({ - data, - accessors = DATE_ACCESSORS, - settings, - labels, - getColor, -}) => { - data = sortTimeSeries(data); - const yTickWidth = getYTickWidth(data, accessors, settings, layout.font.size); - const yLabelOffset = yTickWidth + layout.labelPadding; - const xMin = yLabelOffset + layout.font.size * 1.5; - const xMax = layout.width - layout.margin.right - layout.margin.left; - const yMax = layout.height - layout.margin.bottom; - const innerWidth = xMax - xMin; - const leftLabel = labels?.left; - const bottomLabel = labels?.bottom; - - const entries = calculateWaterfallEntries( - data, - accessors, - settings?.showTotal, - ); - - const xScale = scaleBand({ - domain: entries.map(entry => entry.x), - range: [0, xMax], - padding: layout.barPadding, - }); - - const yScale = scaleLinear({ - domain: calculateWaterfallDomain(entries), - range: [yMax, 0], - }); - - const getBarProps = entry => { - const width = xScale.bandwidth(); - - const height = Math.abs(yScale(entry.start) - yScale(entry.end)); - const x = xScale(entry.x); - const y = yScale(Math.max(entry.start, entry.end)); - const fill = getWaterfallEntryColor( - entry, - getWaterfallColors(settings?.colors, getColor), - ); - - return { x, y, width, height, fill }; - }; - - return ( - <svg width={layout.width} height={layout.height}> - <Group top={layout.margin.top} left={xMin}> - <GridRows - scale={yScale} - width={innerWidth} - strokeDasharray={layout.strokeDasharray} - /> - {entries.map((entry, index) => ( - <Bar key={index} {...getBarProps(entry)} /> - ))} - </Group> - <AxisLeft - scale={yScale} - top={layout.margin.top} - left={xMin} - label={leftLabel} - labelOffset={yLabelOffset} - hideTicks - hideAxisLine - labelProps={getLabelProps(layout, getColor)} - tickFormat={value => formatNumber(value, settings?.y)} - tickLabelProps={() => getYTickLabelProps(layout, getColor)} - /> - <AxisBottom - scale={xScale} - left={xMin} - top={yMax + layout.margin.top} - label={bottomLabel} - numTicks={layout.numTicks} - stroke={getColor("text-light")} - tickStroke={getColor("text-light")} - labelProps={getLabelProps(layout, getColor)} - tickFormat={value => formatTimescaleWaterfallTick(value, settings)} - tickLabelProps={() => getXTickLabelProps(layout, false, getColor)} - /> - </svg> - ); -}; - -TimeSeriesWaterfallChart.propTypes = propTypes; - -export default TimeSeriesWaterfallChart; diff --git a/frontend/src/metabase/static-viz/components/TimeSeriesWaterfallChart/constants.ts b/frontend/src/metabase/static-viz/components/TimeSeriesWaterfallChart/constants.ts deleted file mode 100644 index 7046f7608fbe5c064f502326f6fc70746a1f9fe7..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/static-viz/components/TimeSeriesWaterfallChart/constants.ts +++ /dev/null @@ -1,19 +0,0 @@ -export const TIME_SERIES_WATERFALL_CHART_TYPE = "timeseries/waterfall"; - -export const TIME_SERIES_WATERFALL_CHART_DEFAULT_OPTIONS = { - data: [ - ["2020-10-20", 20], - ["2020-10-21", 20], - ["2020-10-22", 100], - ["2020-10-23", -10], - ["2020-10-24", 20], - ["2020-10-25", -30], - ["2020-10-26", -10], - ["2020-10-27", 20], - ["2020-10-28", -15], - ], - labels: { - left: "Count", - bottom: "Created At", - }, -}; diff --git a/frontend/src/metabase/static-viz/components/TimeSeriesWaterfallChart/index.js b/frontend/src/metabase/static-viz/components/TimeSeriesWaterfallChart/index.js deleted file mode 100644 index 462d2653921943e124d0f82546575cafbd9b2f24..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/static-viz/components/TimeSeriesWaterfallChart/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./TimeSeriesWaterfallChart"; diff --git a/frontend/src/metabase/static-viz/components/XYChart/Values/Values.tsx b/frontend/src/metabase/static-viz/components/Values/Values.tsx similarity index 80% rename from frontend/src/metabase/static-viz/components/XYChart/Values/Values.tsx rename to frontend/src/metabase/static-viz/components/Values/Values.tsx index 86ff531ce434e90fa4600f25ed0a73c9ded64a3d..104e03909311454110db058a9a2954b450166a9b 100644 --- a/frontend/src/metabase/static-viz/components/XYChart/Values/Values.tsx +++ b/frontend/src/metabase/static-viz/components/Values/Values.tsx @@ -6,7 +6,7 @@ import { scaleBand } from "@visx/scale"; import type { TextProps } from "@visx/text"; import type { AnyScaleBand, PositionScale } from "@visx/shape/lib/types"; import OutlinedText from "metabase/static-viz/components/Text/OutlinedText"; -import { getValueStep, getY } from "../utils"; +import { getValueStep, getY, setY } from "../XYChart/utils"; import type { HydratedSeries, @@ -14,7 +14,7 @@ import type { StackedDatum, VisualizationType, XScale, -} from "../types"; +} from "../XYChart/types"; type XYAccessor< T extends SeriesDatum | StackedDatum = SeriesDatum | StackedDatum, @@ -23,8 +23,11 @@ type XYAccessor< flipped?: boolean, ) => number; +type Settings = Record<string, any>; + const VALUES_MARGIN = 6; -const VALUES_STROKE_MARGIN = 3; +// From testing 1px is equal 3px of the stroke width, I'm not totally sure why. +const VALUES_STROKE_MARGIN = 1; const FLIPPED_VALUES_MARGIN = VALUES_MARGIN + 8; interface ValuesProps { @@ -37,10 +40,12 @@ interface ValuesProps { innerWidth: number; areStacked: boolean; xAxisYPos: number; + settings?: Settings; } interface Value { datum: SeriesDatum | StackedDatum; + datumForLabel: SeriesDatum | StackedDatum; flipped?: boolean; hidden?: boolean; } @@ -61,6 +66,7 @@ export default function Values({ innerWidth, areStacked, xAxisYPos, + settings, }: ValuesProps) { const containBars = Boolean(xScale.bandwidth); const barSeriesIndexMap = new WeakMap(); @@ -82,10 +88,10 @@ export default function Values({ const singleSeriesValues = series.type === "bar" ? fixSmallBarChartValues( - getValues(series, areStacked), + getValues(series, areStacked, settings), innerBarScale?.domain().length ?? 0, ) - : getValues(series, areStacked); + : getValues(series, areStacked, settings); return singleSeriesValues.map(value => { return { @@ -160,20 +166,18 @@ export default function Values({ ["line", "area"] as VisualizationType[] ).includes(value.series.type); return ( - <> + <React.Fragment key={index}> <OutlinedText - key={index} x={xAccessor(value.datum)} y={yAccessor(value.datum)} textAnchor="middle" verticalAnchor="end" {...valueProps} > - {formatter(getY(value.datum), compact)} + {formatter(getY(value.datumForLabel), compact)} </OutlinedText> {shouldRenderDataPoint && ( <circle - key={index} r={3} fill="white" stroke={value.series.color} @@ -182,7 +186,7 @@ export default function Values({ cy={dataYAccessor(value.datum)} /> )} - </> + </React.Fragment> ); }); })} @@ -218,10 +222,14 @@ export default function Values({ } } -function getValues(series: HydratedSeries, areStacked: boolean): Value[] { +function getValues( + series: HydratedSeries, + areStacked: boolean, + settings?: Settings, +): Value[] { const data = getData(series, areStacked); - return transformDataToValues(series.type, data); + return transformDataToValues(series.type, data, settings); } function getData(series: HydratedSeries, areStacked: boolean) { @@ -257,13 +265,16 @@ function getXAccessor( xScale: XScale, barXOffset: number, ): XYAccessor { - if (type === "bar") { - return datum => (xScale.barAccessor as XYAccessor)(datum) + barXOffset; - } - if (type === "line" || type === "area") { - return xScale.lineAccessor as XYAccessor; + switch (type) { + case "bar": + return datum => (xScale.barAccessor as XYAccessor)(datum) + barXOffset; + case "line": + case "area": + case "waterfall": + return xScale.lineAccessor as XYAccessor; + default: + exhaustiveCheck(type); } - exhaustiveCheck(type); } function exhaustiveCheck(param: never): never { @@ -273,37 +284,52 @@ function exhaustiveCheck(param: never): never { function transformDataToValues( type: VisualizationType, data: (SeriesDatum | StackedDatum)[], + settings?: Settings, ): Value[] { - if (type === "line") { - return data.map((datum, index) => { - // Use the similar logic as presented in https://github.com/metabase/metabase/blob/3f4ca9c70bd263a7579613971ea8d7c47b1f776e/frontend/src/metabase/visualizations/lib/chart_values.js#L130 - const previousValue = data[index - 1]; - const nextValue = data[index + 1]; - const showLabelBelow = - // first point or prior is greater than y - (index === 0 || getY(previousValue) > getY(datum)) && - // last point point or next is greater than y - (index >= data.length - 1 || getY(nextValue) > getY(datum)); - - return { - datum, - flipped: showLabelBelow, - }; - }); - } - - if (type === "bar") { - return data.map(datum => { - const isNegative = getY(datum) < 0; - return { datum, flipped: isNegative }; - }); + switch (type) { + case "line": + return data.map((datum, index) => { + // Use the similar logic as presented in https://github.com/metabase/metabase/blob/3f4ca9c70bd263a7579613971ea8d7c47b1f776e/frontend/src/metabase/visualizations/lib/chart_values.js#L130 + const previousValue = data[index - 1]; + const nextValue = data[index + 1]; + const showLabelBelow = + // first point or prior is greater than y + (index === 0 || getY(previousValue) > getY(datum)) && + // last point point or next is greater than y + (index >= data.length - 1 || getY(nextValue) > getY(datum)); + + return { + datum, + datumForLabel: datum, + flipped: showLabelBelow, + }; + }); + case "bar": + return data.map(datum => { + const isNegative = getY(datum) < 0; + return { datum, datumForLabel: datum, flipped: isNegative }; + }); + case "area": + return data.map(datum => { + return { + datum, + datumForLabel: datum, + }; + }); + case "waterfall": { + let total = 0; + return data.map((datum, index) => { + total = total + getY(datum); + const isShowingTotal = settings?.showTotal && index === data.length - 1; + return { + datum: !isShowingTotal ? setY(datum, total) : datum, + datumForLabel: datum, + }; + }); + } + default: + exhaustiveCheck(type); } - - return data.map(datum => { - return { - datum, - }; - }); } interface Position { diff --git a/frontend/src/metabase/static-viz/components/XYChart/Values/index.ts b/frontend/src/metabase/static-viz/components/Values/index.ts similarity index 100% rename from frontend/src/metabase/static-viz/components/XYChart/Values/index.ts rename to frontend/src/metabase/static-viz/components/Values/index.ts diff --git a/frontend/src/metabase/static-viz/components/WaterfallChart/WaterfallChart.jsx b/frontend/src/metabase/static-viz/components/WaterfallChart/WaterfallChart.jsx new file mode 100644 index 0000000000000000000000000000000000000000..f5e338cf50ef6223c714f65112ab3025cb43f20d --- /dev/null +++ b/frontend/src/metabase/static-viz/components/WaterfallChart/WaterfallChart.jsx @@ -0,0 +1,343 @@ +import React from "react"; +import { AxisBottom, AxisLeft } from "@visx/axis"; +import { GridRows } from "@visx/grid"; +import { scaleBand, scaleLinear } from "@visx/scale"; +import { Bar } from "@visx/shape"; +import { Group } from "@visx/group"; +import { Text } from "@visx/text"; +import { assoc, merge } from "icepick"; +import { + getLabelProps, + getXTickLabelProps, + getXTickWidth, + getYTickLabelProps, + getYTickWidth, +} from "metabase/static-viz/lib/axes"; +import { formatNumber } from "metabase/static-viz/lib/numbers"; +import { + calculateWaterfallDomain, + calculateWaterfallEntries, + calculateWaterfallSeriesForValues, + formatTimescaleWaterfallTick, + getWaterfallEntryColor, +} from "metabase/static-viz/lib/waterfall"; +import { measureTextHeight, truncateText } from "metabase/static-viz/lib/text"; +import { sortTimeSeries } from "../../lib/sort"; +import { + DATE_ACCESSORS, + POSITIONAL_ACCESSORS, +} from "../../constants/accessors"; +import { getWaterfallColors } from "../../lib/colors"; +import Values from "../Values"; +import { createXScale } from "../XYChart/utils"; + +const layout = { + width: 540, + height: 300, + margin: { + // Add some margin so when the chart scale down, + // elements that are rendered at the top of the chart doesn't get cut off + top: 12, + left: 55, + right: 40, + }, + barPadding: 0.2, + labelPadding: 12, + strokeDasharray: "4", + numTicks: 4, + maxTickWidth: 100, +}; + +// If inner chart area is smaller than this then it will look cramped +const MIN_INNER_HEIGHT = 250; +const MAX_EXTRA_HEIGHT = 40; +// The default value for tick length +const TICK_LENGTH = 8; +const VALUES_MARGIN = 6; + +// Since we're using JSDoc instead, which provide more static type checking than PropTypes. +/* eslint-disable react/prop-types */ +/** + * + * @param {import("./types").WaterfallChartProps} props + * @returns {JSX.Element} + */ +function WaterfallChart({ + data, + type, + accessors = getDefaultAccessors(type), + settings, + labels, + getColor, +}) { + const chartStyle = { + fontFamily: "Lato, sans-serif", + axes: { + color: getColor("text-light"), + ticks: { + color: getColor("text-medium"), + fontSize: 12, + }, + labels: { + color: getColor("text-medium"), + fontSize: 14, + fontWeight: 700, + }, + }, + value: { + color: getColor("text-dark"), + fontSize: 12, + fontWeight: 800, + stroke: getColor("white"), + strokeWidth: 3, + }, + }; + + const axesProps = { + stroke: chartStyle.axes.color, + tickStroke: chartStyle.axes.color, + }; + + const valueProps = { + fontSize: chartStyle.value?.fontSize, + fontFamily: chartStyle.fontFamily, + fontWeight: chartStyle.value?.fontWeight, + letterSpacing: 0.5, + fill: chartStyle.value?.color, + stroke: chartStyle.value?.stroke, + strokeWidth: chartStyle.value?.strokeWidth, + }; + + if (type === "timeseries") { + data = sortTimeSeries(data); + } + const entries = calculateWaterfallEntries( + data, + accessors, + settings?.showTotal, + ); + + const isVertical = type === "timeseries" ? false : entries.length > 10; + const xTickWidth = + type === "timeseries" + ? // We don't know the width of the time-series label because it's formatted inside `<AxisBottom />`. + // We could extract those logic out, but it's gonna be nasty, and we didn't need `xTickWidth` for time-series anyway. + 0 + : getXTickWidth( + data, + accessors, + layout.maxTickWidth, + chartStyle.axes.ticks.fontSize, + ); + + const getXTickProps = + type === "timeseries" + ? ({ formattedValue, ...props }) => { + return { + ...props, + children: formatTimescaleWaterfallTick(formattedValue, settings), + }; + } + : ({ x, y, formattedValue, ...props }) => { + const textWidth = isVertical ? xTickWidth : xScale.bandwidth(); + const truncatedText = truncateText( + formattedValue, + textWidth, + chartStyle.axes.ticks.fontSize, + ); + const xTickFontSize = chartStyle.axes.ticks.fontSize; + const transform = isVertical + ? `rotate(-90, ${x} ${y}) translate(${Math.floor( + xTickFontSize * 0.9, + )} ${Math.floor(xTickFontSize / 3)})` + : undefined; + + const textAnchor = isVertical ? "end" : "middle"; + + return { + ...props, + x, + y, + transform, + children: truncatedText, + textAnchor, + }; + }; + + const numTicks = type === "timeseries" ? layout.numTicks : entries.length; + const tickLabelProps = getXTickLabelProps(chartStyle, isVertical); + const topMargin = settings.show_values + ? layout.margin.top + VALUES_MARGIN + : layout.margin.top; + + const xTickHeight = isVertical ? xTickWidth : chartStyle.axes.ticks.fontSize; + const yTickWidth = getYTickWidth( + data, + accessors, + settings, + chartStyle.axes.ticks.fontSize, + ); + const yLabelOffset = yTickWidth + layout.labelPadding; + const xMin = yLabelOffset + chartStyle.axes.labels.fontSize * 1.5; + const xMax = layout.width - layout.margin.right - layout.margin.left; + const xAxisHeight = getXAxisHeight( + xTickHeight, + measureTextHeight(chartStyle.axes.labels.fontSize), + ); + let yMax = layout.height - xAxisHeight - topMargin; + let height = layout.height; + // If inner chart area is too short, try to expand it but not more than `MAX_EXTRA_HEIGHT` + // to match what we do with XYChart (with legends, it can be up to 340px tall) + if (yMax < MIN_INNER_HEIGHT) { + yMax = + minMax( + layout.height, + layout.height + MAX_EXTRA_HEIGHT, + yMax + xAxisHeight + MAX_EXTRA_HEIGHT, + ) - xAxisHeight; + height = topMargin + yMax + xAxisHeight; + } + const leftLabel = labels?.left; + + const xScale = scaleBand({ + domain: entries.map(entry => entry.x), + range: [0, xMax], + padding: layout.barPadding, + }); + + const yScale = scaleLinear({ + domain: calculateWaterfallDomain(entries), + range: [yMax, 0], + }); + + const getBarProps = entry => { + const width = xScale.bandwidth(); + + const height = Math.abs(yScale(entry.start) - yScale(entry.end)); + const x = xScale(entry.x); + const y = yScale(Math.max(entry.start, entry.end)); + + const fill = getWaterfallEntryColor( + entry, + getWaterfallColors(settings?.colors, getColor), + ); + + return { x, y, width, height, fill }; + }; + + // Used only for rendering data point values + const series = calculateWaterfallSeriesForValues( + data, + accessors, + settings?.showTotal, + ); + + return ( + <svg width={layout.width} height={height}> + <Group top={topMargin} left={xMin}> + <GridRows + scale={yScale} + width={xMax} + strokeDasharray={layout.strokeDasharray} + /> + </Group> + + <AxisLeft + scale={yScale} + top={topMargin} + left={xMin} + label={leftLabel} + labelOffset={yLabelOffset} + hideTicks + hideAxisLine + labelProps={getLabelProps(chartStyle)} + tickFormat={value => formatNumber(value, settings?.y)} + tickLabelProps={() => getYTickLabelProps(chartStyle)} + {...axesProps} + /> + <AxisBottom + scale={xScale} + left={xMin} + top={yMax + topMargin} + label={labels?.bottom} + tickLength={TICK_LENGTH} + numTicks={numTicks} + labelProps={merge(getLabelProps(chartStyle, getColor), { + dy: + -measureTextHeight(chartStyle.axes.labels.fontSize) + + xTickHeight + + layout.labelPadding, + })} + tickComponent={props => <Text {...getXTickProps(props)} />} + tickLabelProps={() => tickLabelProps} + {...axesProps} + /> + <Group top={topMargin} left={xMin}> + {entries.map((entry, index) => ( + <Bar key={index} {...getBarProps(entry)} /> + ))} + {settings.show_values && ( + <Values + series={series} + formatter={(value, compact) => + formatNumber(value, maybeAssoc(settings.y, "compact", compact)) + } + valueProps={valueProps} + xScale={createXScale(series, [0, xMax], "ordinal")} + yScaleLeft={yScale} + yScaleRight={null} + innerWidth={xMax} + xAxisYPos={yMax} + settings={settings} + /> + )} + </Group> + </svg> + ); +} + +/** + * + * @param {'timeseries'|'categorical'} type + * @returns {typeof POSITIONAL_ACCESSORS} + */ +function getDefaultAccessors(type) { + if (type === "timeseries") { + return DATE_ACCESSORS; + } + + if (type === "categorical") { + return POSITIONAL_ACCESSORS; + } +} + +/** + * + * @param {number} xTickHeight + * @param {number} xLabelHeight + * @returns {number} The height of the X-axis section of the chart + */ +function getXAxisHeight(xTickHeight, xLabelHeight) { + // x-axis height = tick length (dash) + tick label height + label padding + label height + return TICK_LENGTH + xTickHeight + layout.labelPadding + xLabelHeight; +} + +/** + * + * @param {number} min + * @param {number} max + * @param {number} value + */ +function minMax(min, max, value) { + return Math.min(max, Math.max(min, value)); +} + +const maybeAssoc = (collection, key, value) => { + if (collection == null) { + return collection; + } + + return assoc(collection, key, value); +}; + +export default WaterfallChart; diff --git a/frontend/src/metabase/static-viz/components/WaterfallChart/constants.ts b/frontend/src/metabase/static-viz/components/WaterfallChart/constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..208c2147372d5a2c6d372a30af2b9e0e3c7b7c9b --- /dev/null +++ b/frontend/src/metabase/static-viz/components/WaterfallChart/constants.ts @@ -0,0 +1,47 @@ +export const WATERFALL_CHART_TYPE = "waterfall"; + +export const TIME_SERIES_WATERFALL_CHART_DEFAULT_OPTIONS = { + data: [ + ["2020-10-20", 20], + ["2020-10-21", 20], + ["2020-10-22", 100], + ["2020-10-23", -10], + ["2020-10-24", 20], + ["2020-10-25", -30], + ["2020-10-26", -10], + ["2020-10-27", 20], + ["2020-10-28", -15], + ], + settings: { + show_values: true, + }, + labels: { + left: "Count", + bottom: "Created At", + }, + type: "timeseries", +}; + +export const CATEGORICAL_WATERFALL_CHART_DEFAULT_OPTIONS = { + data: [ + ["Stage 1", 800], + ["Stage 2", 400], + ["Stage 3", -300], + ["Stage 4", -100], + ["Stage 5", -50], + ["Stage 6", 200], + ["Stage 7", -100], + ["Stage 8", 300], + ["Stage 9", 100], + ["Stage 10", -300], + ], + settings: { + showTotal: true, + show_values: true, + }, + labels: { + left: "Count", + bottom: "Created At", + }, + type: "categorical", +}; diff --git a/frontend/src/metabase/static-viz/components/WaterfallChart/index.js b/frontend/src/metabase/static-viz/components/WaterfallChart/index.js new file mode 100644 index 0000000000000000000000000000000000000000..57657039be879d425437250ea357c5396e3cb3d2 --- /dev/null +++ b/frontend/src/metabase/static-viz/components/WaterfallChart/index.js @@ -0,0 +1 @@ +export { default } from "./WaterfallChart"; diff --git a/frontend/src/metabase/static-viz/components/WaterfallChart/types.ts b/frontend/src/metabase/static-viz/components/WaterfallChart/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..a7614473d59aba40c3212989066d3dd2fd2e25e4 --- /dev/null +++ b/frontend/src/metabase/static-viz/components/WaterfallChart/types.ts @@ -0,0 +1,25 @@ +import type { ColorGetter } from "metabase/static-viz/lib/colors"; + +type XYAccessor = (row: any[]) => any; + +export interface WaterfallChartProps { + data: any[]; + accessors: { + x: XYAccessor; + y: XYAccessor; + }; + + settings: { + x: object; + y: object; + colors: object; + showTotal: boolean; + show_values: boolean; + }; + labels: { + left: string; + bottom: string; + }; + getColor: ColorGetter; + type: "categorical" | "timeseries"; +} diff --git a/frontend/src/metabase/static-viz/components/XYChart/XYChart.tsx b/frontend/src/metabase/static-viz/components/XYChart/XYChart.tsx index 11a1c6a2b26b88da94ccbb5263104d9fe7cefb15..659f9c1b23c681ebd14f5c19b8afc13148459f6f 100644 --- a/frontend/src/metabase/static-viz/components/XYChart/XYChart.tsx +++ b/frontend/src/metabase/static-viz/components/XYChart/XYChart.tsx @@ -39,7 +39,7 @@ import type { ChartStyle, HydratedSeries, } from "metabase/static-viz/components/XYChart/types"; -import Values from "./Values"; +import Values from "../Values"; export interface XYChartProps { width: number; diff --git a/frontend/src/metabase/static-viz/components/XYChart/types.ts b/frontend/src/metabase/static-viz/components/XYChart/types.ts index f1b27b3052463aa1f7d246decdc0590a2ce012dc..11e4a9eba2404654adc67b4b4146dc146e18dd6d 100644 --- a/frontend/src/metabase/static-viz/components/XYChart/types.ts +++ b/frontend/src/metabase/static-viz/components/XYChart/types.ts @@ -15,7 +15,7 @@ export type YAxisType = "linear" | "pow" | "log"; export type YAxisPosition = "left" | "right"; -export type VisualizationType = "line" | "area" | "bar"; +export type VisualizationType = "line" | "area" | "bar" | "waterfall"; export type Series = { name: string; diff --git a/frontend/src/metabase/static-viz/components/XYChart/utils/scales.ts b/frontend/src/metabase/static-viz/components/XYChart/utils/scales.ts index ee3493f85af1f0e6d7d7e2039e260d79a4709059..ccdbf80499e85050eef40ff67ad59c3be3c12e58 100644 --- a/frontend/src/metabase/static-viz/components/XYChart/utils/scales.ts +++ b/frontend/src/metabase/static-viz/components/XYChart/utils/scales.ts @@ -42,7 +42,7 @@ export const createXScale = ( const xScale = scaleBand({ domain, range, - padding: 0.1, + padding: 0.2, }); return { diff --git a/frontend/src/metabase/static-viz/components/XYChart/utils/series.ts b/frontend/src/metabase/static-viz/components/XYChart/utils/series.ts index f164df89b7d663d53e9b6833ea1955a17e2db316..c5bfe0549ea5913c4939020eb4ab793891730372 100644 --- a/frontend/src/metabase/static-viz/components/XYChart/utils/series.ts +++ b/frontend/src/metabase/static-viz/components/XYChart/utils/series.ts @@ -8,6 +8,14 @@ import { export const getX = (d: SeriesDatum | StackedDatum) => d[0]; export const getY = (d: SeriesDatum | StackedDatum) => d[1]; +export const setY = <T extends SeriesDatum | StackedDatum>( + d: T, + value: number, +): T => { + const newDatum = [...d]; + newDatum[1] = value; + return newDatum as T; +}; export const getY1 = (d: StackedDatum) => d[2]; diff --git a/frontend/src/metabase/static-viz/components/XYChart/utils/ticks.ts b/frontend/src/metabase/static-viz/components/XYChart/utils/ticks.ts index 16cb21820d44d4f9dd2d10364be76fd9224151d5..fbc2d909ae15bc21f254b7e939b875d404281532 100644 --- a/frontend/src/metabase/static-viz/components/XYChart/utils/ticks.ts +++ b/frontend/src/metabase/static-viz/components/XYChart/utils/ticks.ts @@ -25,7 +25,7 @@ import type { ChartSettings, } from "metabase/static-viz/components/XYChart/types"; -export const getRotatedXTickHeight = (tickWidth: number) => { +const getRotatedXTickHeight = (tickWidth: number) => { return tickWidth; }; diff --git a/frontend/src/metabase/static-viz/containers/StaticChart/StaticChart.tsx b/frontend/src/metabase/static-viz/containers/StaticChart/StaticChart.tsx index f023616b48249efe476a74e54bc093d90e5caabb..9f02491ee2828ed4409f1c3ad77f1c5191b049d3 100644 --- a/frontend/src/metabase/static-viz/containers/StaticChart/StaticChart.tsx +++ b/frontend/src/metabase/static-viz/containers/StaticChart/StaticChart.tsx @@ -2,10 +2,8 @@ import React from "react"; import { createColorGetter } from "metabase/static-viz/lib/colors"; import CategoricalDonutChart from "../../components/CategoricalDonutChart"; import { CATEGORICAL_DONUT_CHART_TYPE } from "../../components/CategoricalDonutChart/constants"; -import CategoricalWaterfallChart from "../../components/CategoricalWaterfallChart"; -import { CATEGORICAL_WATERFALL_CHART_TYPE } from "../../components/CategoricalWaterfallChart/constants"; -import TimeSeriesWaterfallChart from "../../components/TimeSeriesWaterfallChart"; -import { TIME_SERIES_WATERFALL_CHART_TYPE } from "../../components/TimeSeriesWaterfallChart/constants"; +import WaterfallChart from "../../components/WaterfallChart"; +import { WATERFALL_CHART_TYPE } from "../../components/WaterfallChart/constants"; import ProgressBar from "../../components/ProgressBar"; import { PROGRESS_BAR_TYPE } from "../../components/ProgressBar/constants"; import LineAreaBarChart from "../../components/LineAreaBarChart"; @@ -21,10 +19,8 @@ const StaticChart = ({ type, options }: StaticChartProps) => { switch (type) { case CATEGORICAL_DONUT_CHART_TYPE: return <CategoricalDonutChart {...chartProps} />; - case CATEGORICAL_WATERFALL_CHART_TYPE: - return <CategoricalWaterfallChart {...chartProps} />; - case TIME_SERIES_WATERFALL_CHART_TYPE: - return <TimeSeriesWaterfallChart {...chartProps} />; + case WATERFALL_CHART_TYPE: + return <WaterfallChart {...chartProps} />; case PROGRESS_BAR_TYPE: return <ProgressBar {...chartProps} />; case LINE_AREA_BAR_CHART_TYPE: diff --git a/frontend/src/metabase/static-viz/containers/StaticChart/constants.ts b/frontend/src/metabase/static-viz/containers/StaticChart/constants.ts index 189d86d257fe98b45a0613bed9408df2114a30c0..b66d9dd24a353e576b318b3ee3d2375af004607a 100644 --- a/frontend/src/metabase/static-viz/containers/StaticChart/constants.ts +++ b/frontend/src/metabase/static-viz/containers/StaticChart/constants.ts @@ -2,14 +2,11 @@ import { CATEGORICAL_DONUT_CHART_DEFAULT_OPTIONS, CATEGORICAL_DONUT_CHART_TYPE, } from "../../components/CategoricalDonutChart/constants"; -import { - CATEGORICAL_WATERFALL_CHART_DEFAULT_OPTIONS, - CATEGORICAL_WATERFALL_CHART_TYPE, -} from "../../components/CategoricalWaterfallChart/constants"; import { TIME_SERIES_WATERFALL_CHART_DEFAULT_OPTIONS, - TIME_SERIES_WATERFALL_CHART_TYPE, -} from "../../components/TimeSeriesWaterfallChart/constants"; + CATEGORICAL_WATERFALL_CHART_DEFAULT_OPTIONS, + WATERFALL_CHART_TYPE, +} from "../../components/WaterfallChart/constants"; import { PROGRESS_BAR_DEFAULT_DATA_1, PROGRESS_BAR_TYPE, @@ -25,8 +22,7 @@ import { export const STATIC_CHART_TYPES = [ CATEGORICAL_DONUT_CHART_TYPE, - CATEGORICAL_WATERFALL_CHART_TYPE, - TIME_SERIES_WATERFALL_CHART_TYPE, + WATERFALL_CHART_TYPE, PROGRESS_BAR_TYPE, LINE_AREA_BAR_CHART_TYPE, FUNNEL_CHART_TYPE, diff --git a/frontend/src/metabase/static-viz/lib/axes.js b/frontend/src/metabase/static-viz/lib/axes.js index a6cf5f96ab600d6a6fde69a4883a2bf9cbb05a29..cb722a9d07d4b11fa46425da8706ebe85ddea1aa 100644 --- a/frontend/src/metabase/static-viz/lib/axes.js +++ b/frontend/src/metabase/static-viz/lib/axes.js @@ -14,10 +14,6 @@ export const getXTickWidthFromValues = (values, maxWidth, fontSize) => { return Math.min(tickWidth, maxWidth); }; -export const getRotatedXTickHeight = tickWidth => { - return Math.ceil(Math.sqrt(Math.pow(tickWidth, 2) / 2)); -}; - export const getYTickWidth = (data, accessors, settings, fontSize) => { return data .map(accessors.y) @@ -26,24 +22,37 @@ export const getYTickWidth = (data, accessors, settings, fontSize) => { .reduce((a, b) => Math.max(a, b), 0); }; -export const getXTickLabelProps = (layout, isVertical, getColor) => ({ - fontSize: layout.font.size, - fontFamily: layout.font.family, - fill: getColor("text-medium"), +/** + * + * @param {import("../components/XYChart/types").ChartStyle} chartStyle + * @param {boolean} isVertical + */ +export const getXTickLabelProps = (chartStyle, isVertical) => ({ + fontFamily: chartStyle.fontFamily, + fontSize: chartStyle.axes.ticks.fontSize, + fill: chartStyle.axes.ticks.color, textAnchor: isVertical ? "start" : "middle", }); -export const getYTickLabelProps = (layout, getColor) => ({ - fontSize: layout.font.size, - fontFamily: layout.font.family, - fill: getColor("text-medium"), +/** + * + * @param {import("../components/XYChart/types").ChartStyle} chartStyle + */ +export const getYTickLabelProps = chartStyle => ({ + fontFamily: chartStyle.fontFamily, + fontSize: chartStyle.axes.ticks.fontSize, + fill: chartStyle.axes.ticks.color, textAnchor: "end", }); -export const getLabelProps = (layout, getColor) => ({ - fontWeight: layout.labelFontWeight, - fontSize: layout.font.size, - fontFamily: layout.font.family, - fill: getColor("text-medium"), +/** + * + * @param {import("../components/XYChart/types").ChartStyle} chartStyle + */ +export const getLabelProps = chartStyle => ({ + fontFamily: chartStyle.fontFamily, + fontWeight: chartStyle.axes.labels.fontWeight, + fontSize: chartStyle.axes.labels.fontSize, + fill: chartStyle.axes.labels.color, textAnchor: "middle", }); diff --git a/frontend/src/metabase/static-viz/lib/axes.unit.spec.js b/frontend/src/metabase/static-viz/lib/axes.unit.spec.js index 4f348f1af78c0d8757654332facbf02800aec52d..fcc2942e4f3412d89f7c54a5b8baf7132ed1b69d 100644 --- a/frontend/src/metabase/static-viz/lib/axes.unit.spec.js +++ b/frontend/src/metabase/static-viz/lib/axes.unit.spec.js @@ -1,4 +1,4 @@ -import { getRotatedXTickHeight, getXTickWidth, getYTickWidth } from "./axes"; +import { getXTickWidth, getYTickWidth } from "./axes"; const fontSize = 11; @@ -14,12 +14,6 @@ describe("getXTickWidth", () => { }); }); -describe("getRotatedXTickHeight", () => { - it("should get tick height by width assuming 45deg rotation", () => { - expect(getRotatedXTickHeight(12)).toBe(9); - }); -}); - describe("getYTickWidth", () => { it("should get tick width for y axis assuming 6px char width", () => { const data = [{ y: 1 }, { y: 20 }, { y: 15 }]; diff --git a/frontend/src/metabase/static-viz/lib/waterfall.js b/frontend/src/metabase/static-viz/lib/waterfall.js index 0118f5362602cdb9ab02268f0c67dafbbb444fdf..420802806674a61af1fc6264008e4946759c1c7d 100644 --- a/frontend/src/metabase/static-viz/lib/waterfall.js +++ b/frontend/src/metabase/static-viz/lib/waterfall.js @@ -34,6 +34,31 @@ export const calculateWaterfallEntries = (data, accessors, showTotal) => { return entries; }; +export const calculateWaterfallSeriesForValues = ( + data, + accessors, + showTotal, +) => { + if (!showTotal) { + return [ + { + data, + type: "waterfall", + yAxisPosition: "left", + }, + ]; + } else { + const total = data.reduce((sum, datum) => sum + accessors.y(datum), 0); + return [ + { + data: [...data, [WATERFALL_TOTAL, total]], + type: "waterfall", + yAxisPosition: "left", + }, + ]; + } +}; + export const formatTimescaleWaterfallTick = (value, settings) => value === WATERFALL_TOTAL ? WATERFALL_TOTAL : formatDate(value, settings?.x); diff --git a/frontend/test/metabase-visual/static-visualizations/waterfall.cy.spec.js b/frontend/test/metabase-visual/static-visualizations/waterfall.cy.spec.js index c46c2b90894772845e8df7d06393c796301207ac..3748ca1763a08eb8829070e6b2cb0aeb7d61df83 100644 --- a/frontend/test/metabase-visual/static-visualizations/waterfall.cy.spec.js +++ b/frontend/test/metabase-visual/static-visualizations/waterfall.cy.spec.js @@ -46,7 +46,9 @@ function createWaterfallQuestion({ showTotal } = {}) { "SELECT * FROM ( VALUES ('Stage 1', 10), ('Stage 2', 30), ('Stage 3', -50), ('Stage 4', -10), ('Stage 5', 80), ('Stage 6', 10), ('Stage 7', 15))", "template-tags": {}, }, - visualization_settings: {}, + visualization_settings: { + "graph.show_values": true, + }, display: "waterfall", database: SAMPLE_DB_ID, }; diff --git a/resources/frontend_shared/static_viz_interface.js b/resources/frontend_shared/static_viz_interface.js index c6a7b3855e9bf74417016dbbb5f844a817ed2dc6..d4cc044a36c157ea7f7df051dd40059ea3919100 100644 --- a/resources/frontend_shared/static_viz_interface.js +++ b/resources/frontend_shared/static_viz_interface.js @@ -23,11 +23,12 @@ function combo_chart(series, settings, colors) { }); } -function timeseries_waterfall(data, labels, settings, instanceColors) { - return StaticViz.RenderChart("timeseries/waterfall", { +function waterfall(data, labels, settings, waterfallType, instanceColors) { + return StaticViz.RenderChart("waterfall", { data: toJSArray(data), labels: toJSMap(labels), settings: JSON.parse(settings), + type: waterfallType, colors: JSON.parse(instanceColors), }); } @@ -46,15 +47,6 @@ function categorical_donut(rows, colors) { }); } -function categorical_waterfall(data, labels, settings, instanceColors) { - return StaticViz.RenderChart("categorical/waterfall", { - data: toJSArray(data), - labels: toJSMap(labels), - settings: JSON.parse(settings), - colors: JSON.parse(instanceColors), - }); -} - function progress(data, settings) { return StaticViz.RenderChart("progress", { data: JSON.parse(data), diff --git a/src/metabase/pulse/render/body.clj b/src/metabase/pulse/render/body.clj index da63b66d096771c2708e72072d3907222db01efc..88803cbb03f615b78527ee36cee2768e387b53af 100644 --- a/src/metabase/pulse/render/body.clj +++ b/src/metabase/pulse/render/body.clj @@ -347,7 +347,7 @@ (defn- x-and-y-axis-label-info "Generate the X and Y axis labels passed in as the `labels` argument - to [[metabase.pulse.render.js-svg/timelineseries-waterfall]] and other similar functions for rendering charts with X and Y + to [[metabase.pulse.render.js-svg/waterfall]] and other similar functions for rendering charts with X and Y axes. Respects custom display names in `viz-settings`; otherwise uses `x-col` and `y-col` display names." [x-col y-col viz-settings] {:bottom (or (:graph.x_axis.title_text viz-settings) @@ -763,9 +763,9 @@ rows (map (juxt x-axis-rowfn y-axis-rowfn) (common/row-preprocess x-axis-rowfn y-axis-rowfn rows)) labels (x-and-y-axis-label-info x-col y-col viz-settings) - render-fn (if (isa? (-> cols x-axis-rowfn :effective_type) :type/Temporal) - js-svg/timelineseries-waterfall - js-svg/categorical-waterfall) + waterfall-type (if (isa? (-> cols x-axis-rowfn :effective_type) :type/Temporal) + :timeseries + :categorical) show-total (if (nil? (:waterfall.show_total viz-settings)) true (:waterfall.show_total viz-settings)) @@ -774,12 +774,14 @@ :waterfallTotal (:waterfall.total_color viz-settings) :waterfallPositive (:waterfall.increase_color viz-settings) :waterfallNegative (:waterfall.decrease_color viz-settings)) - (assoc :showTotal show-total)) + (assoc :showTotal show-total) + (assoc :show_values (boolean (:graph.show_values viz-settings)))) image-bundle (image-bundle/make-image-bundle render-type - (render-fn rows - labels - settings))] + (js-svg/waterfall rows + labels + settings + waterfall-type))] {:attachments (when image-bundle (image-bundle/image-bundle->attachment image-bundle)) diff --git a/src/metabase/pulse/render/js_svg.clj b/src/metabase/pulse/render/js_svg.clj index 01b9d7d1558bd3bc51bb2bb4a0f18ff23881d08b..8c036fcc2e86a47ba454064727fc911eee480f33 100644 --- a/src/metabase/pulse/render/js_svg.clj +++ b/src/metabase/pulse/render/js_svg.clj @@ -101,13 +101,14 @@ (defn- svg-string->bytes [s] (-> s parse-svg-string render-svg)) -(defn timelineseries-waterfall - "Clojure entrypoint to render a timeseries waterfall chart. Rows should be tuples of [datetime numeric-value]. Labels is +(defn waterfall + "Clojure entrypoint to render a timeseries or categorical waterfall chart. Rows should be tuples of [datetime numeric-value]. Labels is a map of {:left \"left-label\" :botton \"bottom-label\". Returns a byte array of a png file." - [rows labels settings] - (let [svg-string (.asString (js/execute-fn-name @context "timeseries_waterfall" rows + [rows labels settings waterfall-type] + (let [svg-string (.asString (js/execute-fn-name @context "waterfall" rows (map (fn [[k v]] [(name k) v]) labels) (json/generate-string settings) + (name waterfall-type) (json/generate-string (public-settings/application-colors))))] (svg-string->bytes svg-string))) @@ -134,16 +135,6 @@ (json/generate-string (:colors settings))))] (svg-string->bytes svg-string))) -(defn categorical-waterfall - "Clojure entrypoint to render a categorical waterfall chart. Rows should be tuples of [stringable numeric-value]. Labels is - a map of {:left \"left-label\" :botton \"bottom-label\". Returns a byte array of a png file." - [rows labels settings] - (let [svg-string (.asString (js/execute-fn-name @context "categorical_waterfall" rows - (map (fn [[k v]] [(name k) v]) labels) - (json/generate-string settings) - (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" diff --git a/test/metabase/pulse/render/js_svg_test.clj b/test/metabase/pulse/render/js_svg_test.clj index e4ae6d543a975ba981662ff88f941f0cec01ece8..05dacd5cf5c83bfc541d928e9cc0b23fdd28df5b 100644 --- a/test/metabase/pulse/render/js_svg_test.clj +++ b/test/metabase/pulse/render/js_svg_test.clj @@ -9,7 +9,6 @@ [clojure.set :as set] [clojure.spec.alpha :as s] [clojure.test :refer :all] - [metabase.public-settings :as public-settings] [metabase.pulse.render.js-engine :as js] [metabase.pulse.render.js-svg :as js-svg]) (:import org.apache.batik.anim.dom.SVGOMDocument @@ -232,18 +231,36 @@ (testing "A goal line does exist when goal settings are present in the viz-settings" (is (= goal-label (second goal-node))))))) -(deftest timelineseries-waterfall-test - (let [rows [[#t "2020" 2] - [#t "2021" 3]] - labels {:left "count" :bottom "year"} - settings (json/generate-string {:y {:prefix "prefix" - :decimals 4}})] - (testing "It returns bytes" - (let [svg-bytes (js-svg/timelineseries-waterfall rows labels settings)] - (is (bytes? svg-bytes)))) - (let [svg-string (.asString (js/execute-fn-name @context "timeseries_waterfall" rows labels settings (json/generate-string (public-settings/application-colors))))] - (testing "it returns a valid svg string (no html in it)" - (validate-svg-string :timelineseries-waterfall svg-string))))) +(deftest waterfall-test + (testing "Timeseries Waterfall renders" + (let [rows [[#t "2020" 2] + [#t "2021" 3]] + labels {:left "count" :bottom "year"} + settings (json/generate-string {:y {:prefix "prefix" + :decimals 4}}) + waterfall-type (name :timeseries)] + (testing "It returns bytes" + (let [svg-bytes (js-svg/waterfall rows labels settings waterfall-type)] + (is (bytes? svg-bytes)))) + (let [svg-string (.asString (js/execute-fn-name @context "waterfall" + rows labels settings waterfall-type + (json/generate-string {})))] + (testing "it returns a valid svg string (no html in it)" + (validate-svg-string :timelineseries-waterfall svg-string))))) + (testing "Categorical Waterfall renders" + (let [rows [["One" 20] + ["Two" 30]] + labels {:left "count" :bottom "process step"} + settings (json/generate-string {}) + waterfall-type (name :categorical)] + (testing "It returns bytes" + (let [svg-bytes (js-svg/waterfall rows labels settings waterfall-type)] + (is (bytes? svg-bytes)))) + (let [svg-string (.asString (js/execute-fn-name @context "waterfall" + rows labels settings waterfall-type + (json/generate-string {})))] + (testing "it returns a valid svg string (no html in it)" + (validate-svg-string :categorical-waterfall svg-string)))))) (deftest combo-test (let [rows1 [[#t "1998-03-01T00:00:00Z" 2] @@ -302,14 +319,3 @@ (json/generate-string {:value value :goal goal}) (json/generate-string settings)))] (validate-svg-string :progress svg-string)))) - -(deftest categorical-waterfall-test - (let [rows [["apples" 2] - ["bananas" 3]] - labels {:left "bob" :right "dobbs"} - settings (json/generate-string {})] - (testing "It returns bytes" - (let [svg-bytes (js-svg/categorical-waterfall rows labels {})] - (is (bytes? svg-bytes)))) - (let [svg-string (.asString ^Value (js/execute-fn-name @context "categorical_waterfall" rows labels settings (json/generate-string (public-settings/application-colors))))] - (validate-svg-string :categorical/waterfall svg-string))))