diff --git a/frontend/src/metabase/internal/pages/StaticVizPage.jsx b/frontend/src/metabase/internal/pages/StaticVizPage.jsx index f6ce0808cb7e91f22db5207c0ed5d3fcd76bab97..3bc6ca9618e171010f4bdf51a1071bafd72aab73 100644 --- a/frontend/src/metabase/internal/pages/StaticVizPage.jsx +++ b/frontend/src/metabase/internal/pages/StaticVizPage.jsx @@ -17,6 +17,7 @@ export default function StaticVizPage() { /static-viz/ and see the effects. You might need to hard refresh to see updates. </Text> + <Box py={3}> <Subhead>Line chart with timeseries data</Subhead> <StaticChart @@ -595,6 +596,92 @@ export default function StaticVizPage() { /> </Box> + <Box py={3}> + <Subhead>Stacked area chart</Subhead> + <StaticChart + type="combo-chart" + options={{ + settings: { + stacking: "stack", + x: { + type: "timeseries", + }, + y: { + type: "linear", + format: { + number_style: "currency", + currency: "USD", + currency_style: "symbol", + decimals: 2, + }, + }, + labels: { + left: "Sum", + bottom: "Date", + }, + }, + series: [ + { + name: "series 1", + color: "#509ee3", + yAxisPosition: "left", + type: "area", + data: [ + ["2020-10-18", 10], + ["2020-10-19", 20], + ["2020-10-20", 30], + ["2020-10-21", 40], + ["2020-10-22", 45], + ["2020-10-23", 55], + ], + }, + { + name: "series 2", + color: "#a989c5", + yAxisPosition: "left", + type: "area", + data: [ + ["2020-10-18", 10], + ["2020-10-19", 40], + ["2020-10-20", 80], + ["2020-10-21", 60], + ["2020-10-22", 70], + ["2020-10-23", 65], + ], + }, + { + name: "series 3", + color: "#ef8c8c", + yAxisPosition: "left", + type: "area", + data: [ + ["2020-10-18", -40], + ["2020-10-19", -20], + ["2020-10-20", -10], + ["2020-10-21", -20], + ["2020-10-22", -45], + ["2020-10-23", -55], + ], + }, + { + name: "series 4", + color: "#88bf4d", + yAxisPosition: "left", + type: "area", + data: [ + ["2020-10-18", -40], + ["2020-10-19", -50], + ["2020-10-20", -60], + ["2020-10-21", -20], + ["2020-10-22", -10], + ["2020-10-23", -5], + ], + }, + ], + }} + /> + </Box> + <Box py={3}> <Subhead>Funnel</Subhead> <StaticChart diff --git a/frontend/src/metabase/static-viz/components/XYChart/XYChart.tsx b/frontend/src/metabase/static-viz/components/XYChart/XYChart.tsx index 12ddac22a68c7a0bd43da8ae6286c7ad01c80647..914badc9c90002979513fa149ef9afa46a7dc734 100644 --- a/frontend/src/metabase/static-viz/components/XYChart/XYChart.tsx +++ b/frontend/src/metabase/static-viz/components/XYChart/XYChart.tsx @@ -9,6 +9,7 @@ import { Series, ChartSettings, ChartStyle, + HydratedSeries, } from "metabase/static-viz/components/XYChart/types"; import { LineSeries } from "metabase/static-viz/components/XYChart/shapes/LineSeries"; import { BarSeries } from "metabase/static-viz/components/XYChart/shapes/BarSeries"; @@ -31,6 +32,7 @@ import { calculateYDomains, sortSeries, getLegendColumns, + calculateStackedItems, } from "metabase/static-viz/components/XYChart/utils"; import { GoalLine } from "metabase/static-viz/components/XYChart/GoalLine"; @@ -45,11 +47,16 @@ export interface XYChartProps { export const XYChart = ({ width, height, - series, + series: originalSeries, settings, style, }: XYChartProps) => { - series = sortSeries(series, settings.x.type); + let series: HydratedSeries[] = sortSeries(originalSeries, settings.x.type); + + if (settings.stacking === "stack") { + series = calculateStackedItems(series); + } + const yDomains = calculateYDomains(series, settings.goal?.value); const yTickWidths = getYTickWidths( settings.y.format, @@ -70,6 +77,7 @@ export const XYChart = ({ yTickWidths.left, yTickWidths.right, xTicksDimensions.height, + xTicksDimensions.width, settings.labels, style.axes.ticks.fontSize, !!settings.goal, @@ -148,6 +156,7 @@ export const XYChart = ({ yScaleLeft={yScaleLeft} yScaleRight={yScaleRight} xAccessor={xScale.lineAccessor} + areStacked={settings.stacking === "stack"} /> <LineSeries series={lines} diff --git a/frontend/src/metabase/static-viz/components/XYChart/shapes/AreaSeries.tsx b/frontend/src/metabase/static-viz/components/XYChart/shapes/AreaSeries.tsx index d555d34c8de9d4493a07b5c78fa850082719ff86..278e5b6640f0588050e0b1cc31d24ddb11673444 100644 --- a/frontend/src/metabase/static-viz/components/XYChart/shapes/AreaSeries.tsx +++ b/frontend/src/metabase/static-viz/components/XYChart/shapes/AreaSeries.tsx @@ -7,12 +7,14 @@ import { SeriesDatum, } from "metabase/static-viz/components/XYChart/types"; import { getY } from "metabase/static-viz/components/XYChart/utils"; +import { AreaSeriesStacked } from "./AreaSeriesStacked"; interface AreaSeriesProps { series: Series[]; yScaleLeft: PositionScale | null; yScaleRight: PositionScale | null; xAccessor: (datum: SeriesDatum) => number; + areStacked?: boolean; } export const AreaSeries = ({ @@ -20,7 +22,20 @@ export const AreaSeries = ({ yScaleLeft, yScaleRight, xAccessor, + areStacked, }: AreaSeriesProps) => { + if (areStacked) { + return ( + <AreaSeriesStacked + series={series} + // Stacked charts work only for a single dataset with one dimension and left Y-axis + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + yScale={yScaleLeft!} + xAccessor={xAccessor} + /> + ); + } + return ( <Group> {series.map(s => { diff --git a/frontend/src/metabase/static-viz/components/XYChart/shapes/AreaSeriesStacked.tsx b/frontend/src/metabase/static-viz/components/XYChart/shapes/AreaSeriesStacked.tsx new file mode 100644 index 0000000000000000000000000000000000000000..72818135e5f3adca776595e96a0d8f1c2d9f328b --- /dev/null +++ b/frontend/src/metabase/static-viz/components/XYChart/shapes/AreaSeriesStacked.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { Group } from "@visx/group"; +import { PositionScale } from "@visx/shape/lib/types"; +import { LineArea } from "metabase/static-viz/components/XYChart/shapes/LineArea"; +import { + HydratedSeries, + SeriesDatum, +} from "metabase/static-viz/components/XYChart/types"; +import { getY, getY1 } from "metabase/static-viz/components/XYChart/utils"; + +interface AreaSeriesProps { + series: HydratedSeries[]; + yScale: PositionScale; + xAccessor: (datum: SeriesDatum) => number; +} + +export const AreaSeriesStacked = ({ + series, + yScale, + xAccessor, +}: AreaSeriesProps) => { + return ( + <Group> + {series.map(s => { + return ( + <LineArea + key={s.name} + yScale={yScale} + color={s.color} + data={s.stackedData!} + x={xAccessor as any} + y={d => yScale(getY(d)) ?? 0} + y1={d => yScale(getY1(d)) ?? 0} + /> + ); + })} + </Group> + ); +}; diff --git a/frontend/src/metabase/static-viz/components/XYChart/shapes/LineArea.tsx b/frontend/src/metabase/static-viz/components/XYChart/shapes/LineArea.tsx index 1ad5e282d2eb23006fcbbaf910121fd022f5c3a1..2855a7c556587d4d3da25642541ccba0ad3e56b2 100644 --- a/frontend/src/metabase/static-viz/components/XYChart/shapes/LineArea.tsx +++ b/frontend/src/metabase/static-viz/components/XYChart/shapes/LineArea.tsx @@ -4,8 +4,8 @@ import { AccessorForArrayItem, PositionScale } from "@visx/shape/lib/types"; interface AreaProps<Datum> { x: AccessorForArrayItem<Datum, number>; - y: AccessorForArrayItem<Datum, number>; - y1: number; + y: number | AccessorForArrayItem<Datum, number>; + y1: number | AccessorForArrayItem<Datum, number>; yScale: PositionScale; data: Datum[]; color: string; diff --git a/frontend/src/metabase/static-viz/components/XYChart/types.ts b/frontend/src/metabase/static-viz/components/XYChart/types.ts index b455c34474cab9652124919a7aeda2fc24ab35ed..bb7db880e6b73e1fb558fc76744b4527e7a6bf88 100644 --- a/frontend/src/metabase/static-viz/components/XYChart/types.ts +++ b/frontend/src/metabase/static-viz/components/XYChart/types.ts @@ -24,9 +24,17 @@ export type Series = { yAxisPosition: YAxisPosition; }; +export type StackedDatum = [XValue, YValue, YValue]; + +export type HydratedSeries = Series & { + stackedData?: StackedDatum[]; +}; + type TickDisplay = "show" | "hide" | "rotate-45"; +type Stacking = "stack" | "none"; export type ChartSettings = { + stacking?: Stacking; x: { type: XAxisType; tick_display?: TickDisplay; diff --git a/frontend/src/metabase/static-viz/components/XYChart/utils/margin.ts b/frontend/src/metabase/static-viz/components/XYChart/utils/margin.ts index 94d8d92999ad24977ce89f200200a2f2ee7762b7..3d480560125c2f8253420afe7ccf0461a19bd3bb 100644 --- a/frontend/src/metabase/static-viz/components/XYChart/utils/margin.ts +++ b/frontend/src/metabase/static-viz/components/XYChart/utils/margin.ts @@ -8,6 +8,7 @@ export const GOAL_MARGIN = 6; const calculateSideMargin = ( tickSpace: number, labelFontSize: number, + minMargin: number, label?: string, ) => { let margin = CHART_PADDING + tickSpace; @@ -16,21 +17,34 @@ const calculateSideMargin = ( margin += measureTextHeight(labelFontSize) + LABEL_OFFSET; } - return margin; + return Math.max(margin, minMargin); }; export const calculateMargin = ( leftYTickWidth: number, rightYTickWidth: number, xTickHeight: number, + xTickWidth: number, labels: ChartSettings["labels"], labelFontSize: number, hasGoalLine?: boolean, ) => { + const minHorizontalMargin = xTickWidth / 2; + return { top: hasGoalLine ? GOAL_MARGIN + CHART_PADDING : CHART_PADDING, - left: calculateSideMargin(leftYTickWidth, labelFontSize, labels.left), - right: calculateSideMargin(rightYTickWidth, labelFontSize, labels.right), - bottom: calculateSideMargin(xTickHeight, labelFontSize, labels.bottom), + left: calculateSideMargin( + leftYTickWidth, + labelFontSize, + minHorizontalMargin, + labels.left, + ), + right: calculateSideMargin( + rightYTickWidth, + labelFontSize, + minHorizontalMargin, + labels.right, + ), + bottom: calculateSideMargin(xTickHeight, labelFontSize, 0, labels.bottom), }; }; diff --git a/frontend/src/metabase/static-viz/components/XYChart/utils/margin.unit.spec.ts b/frontend/src/metabase/static-viz/components/XYChart/utils/margin.unit.spec.ts index 7f1cc5ec23f207479235345a49997bfeee41c4d1..2671cee8dd34d6197deb4e6a44071af5082858f3 100644 --- a/frontend/src/metabase/static-viz/components/XYChart/utils/margin.unit.spec.ts +++ b/frontend/src/metabase/static-viz/components/XYChart/utils/margin.unit.spec.ts @@ -12,6 +12,7 @@ describe("calculateMargin", () => { const leftYTickWidth = 100; const rightYTickWidth = 200; const xTickHeight = 300; + const xTickWidth = 20; const labelFontSize = 11; const labels = {}; @@ -20,6 +21,7 @@ describe("calculateMargin", () => { leftYTickWidth, rightYTickWidth, xTickHeight, + xTickWidth, labels, labelFontSize, ); @@ -34,6 +36,7 @@ describe("calculateMargin", () => { const leftYTickWidth = 100; const rightYTickWidth = 200; const xTickHeight = 300; + const xTickWidth = 20; const labelFontSize = 11; const labels = { left: "left label", right: "right label" }; @@ -42,6 +45,7 @@ describe("calculateMargin", () => { leftYTickWidth, rightYTickWidth, xTickHeight, + xTickWidth, labels, labelFontSize, ); 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 243c40c21d79e1920b15becd3bca7520551f53b9..121fd54abff7b08ae5c112704bf7807733b4c863 100644 --- a/frontend/src/metabase/static-viz/components/XYChart/utils/scales.ts +++ b/frontend/src/metabase/static-viz/components/XYChart/utils/scales.ts @@ -13,6 +13,8 @@ import { Range, Series, YAxisType, + HydratedSeries, + StackedDatum, } from "metabase/static-viz/components/XYChart/types"; import { getX, @@ -85,11 +87,13 @@ export const createXScale = ( }; const calculateYDomain = ( - series: Series[], + series: HydratedSeries[], goalValue?: number, ): ContiniousDomain => { const values = series - .flatMap(series => series.data) + .flatMap<SeriesDatum | StackedDatum>( + series => series.stackedData ?? series.data, + ) .map(datum => getY(datum)); const minValue = min(values); const maxValue = max(values); @@ -100,7 +104,10 @@ const calculateYDomain = ( ]; }; -export const calculateYDomains = (series: Series[], goalValue?: number) => { +export const calculateYDomains = ( + series: HydratedSeries[], + goalValue?: number, +) => { const leftScaleSeries = series.filter( series => series.yAxisPosition === "left", ); 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 0b2476b33048ded3cc58f4c6ffd9bcf7770c7cda..f164df89b7d663d53e9b6833ea1955a17e2db316 100644 --- a/frontend/src/metabase/static-viz/components/XYChart/utils/series.ts +++ b/frontend/src/metabase/static-viz/components/XYChart/utils/series.ts @@ -3,10 +3,13 @@ import { Series, SeriesDatum, XAxisType, + StackedDatum, } from "metabase/static-viz/components/XYChart/types"; -export const getX = (d: SeriesDatum) => d[0]; -export const getY = (d: SeriesDatum) => d[1]; +export const getX = (d: SeriesDatum | StackedDatum) => d[0]; +export const getY = (d: SeriesDatum | StackedDatum) => d[1]; + +export const getY1 = (d: StackedDatum) => d[2]; export const partitionByYAxis = (series: Series[]) => { return _.partition( @@ -38,3 +41,31 @@ export const sortSeries = (series: Series[], type: XAxisType) => { }; }); }; + +export const calculateStackedItems = (series: Series[]) => { + // Stacked charts work only for a single dataset with one dimension + return series.map((s, seriesIndex) => { + const stackedData = s.data.map((datum, datumIndex) => { + const [x, y] = datum; + + let y1 = 0; + + for (let i = 0; i < seriesIndex; i++) { + const currentY = getY(series[i].data[datumIndex]); + + const hasSameSign = (y > 0 && currentY > 0) || (y < 0 && currentY < 0); + if (hasSameSign) { + y1 += currentY; + } + } + + const stackedDatum: StackedDatum = [x, y1 + y, y1]; + return stackedDatum; + }); + + return { + ...s, + stackedData, + }; + }); +}; diff --git a/frontend/src/metabase/static-viz/components/XYChart/utils/series.unit.spec.ts b/frontend/src/metabase/static-viz/components/XYChart/utils/series.unit.spec.ts index 52058fa5a7374a6248786c0c2bf0dc25626ec7b2..6fec2fd48ff56d239c5872fbfbab24f07a521d36 100644 --- a/frontend/src/metabase/static-viz/components/XYChart/utils/series.unit.spec.ts +++ b/frontend/src/metabase/static-viz/components/XYChart/utils/series.unit.spec.ts @@ -1,4 +1,5 @@ -import { sortSeries } from "./series"; +import { Series } from "../types"; +import { calculateStackedItems, sortSeries } from "./series"; describe("sortSeries", () => { it("sorts timeseries data", () => { @@ -100,3 +101,68 @@ describe("sortSeries", () => { ]); }); }); + +describe("calculateStackedItems", () => { + const series: Series[] = [ + { + name: "series 1", + color: "#509ee3", + yAxisPosition: "left", + type: "area", + data: [ + ["2020-10-18", 10], + ["2020-10-19", -10], + ], + }, + { + name: "series 2", + color: "#a989c5", + yAxisPosition: "left", + type: "area", + data: [ + ["2020-10-18", 20], + ["2020-10-19", -20], + ], + }, + { + name: "series 3", + color: "#ef8c8c", + yAxisPosition: "left", + type: "area", + data: [ + ["2020-10-18", -30], + ["2020-10-19", 30], + ], + }, + ]; + + it("calculates stacked items separating positive and negative values", () => { + const stackedSeries = calculateStackedItems(series); + + /** + * + * 30| 2 3 + * 20| 2 3 + * 10| 1 3 + * --------- + * -10| 3 1 + * -20| 3 2 + * -30| 3 2 + * + */ + expect(stackedSeries.map(s => s.stackedData)).toStrictEqual([ + [ + ["2020-10-18", 10, 0], + ["2020-10-19", -10, 0], + ], + [ + ["2020-10-18", 30, 10], + ["2020-10-19", -30, -10], + ], + [ + ["2020-10-18", -30, 0], + ["2020-10-19", 30, 0], + ], + ]); + }); +});