From ce4d4e6760254a922aa498399c4924e42821b6a8 Mon Sep 17 00:00:00 2001
From: Alexander Lesnenko <alxnddr@users.noreply.github.com>
Date: Mon, 31 Jan 2022 15:12:01 +0000
Subject: [PATCH] support stacked area static viz series (#20004)

* support stacked area static viz series

* fix x-ticks overflow

* fix types

* add specs, fix area static charts
---
 .../metabase/internal/pages/StaticVizPage.jsx | 87 +++++++++++++++++++
 .../static-viz/components/XYChart/XYChart.tsx | 13 ++-
 .../components/XYChart/shapes/AreaSeries.tsx  | 15 ++++
 .../XYChart/shapes/AreaSeriesStacked.tsx      | 39 +++++++++
 .../components/XYChart/shapes/LineArea.tsx    |  4 +-
 .../static-viz/components/XYChart/types.ts    |  8 ++
 .../components/XYChart/utils/margin.ts        | 22 ++++-
 .../XYChart/utils/margin.unit.spec.ts         |  4 +
 .../components/XYChart/utils/scales.ts        | 13 ++-
 .../components/XYChart/utils/series.ts        | 35 +++++++-
 .../XYChart/utils/series.unit.spec.ts         | 68 ++++++++++++++-
 11 files changed, 294 insertions(+), 14 deletions(-)
 create mode 100644 frontend/src/metabase/static-viz/components/XYChart/shapes/AreaSeriesStacked.tsx

diff --git a/frontend/src/metabase/internal/pages/StaticVizPage.jsx b/frontend/src/metabase/internal/pages/StaticVizPage.jsx
index f6ce0808cb7..3bc6ca9618e 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 12ddac22a68..914badc9c90 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 d555d34c8de..278e5b6640f 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 00000000000..72818135e5f
--- /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 1ad5e282d2e..2855a7c5565 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 b455c34474c..bb7db880e6b 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 94d8d92999a..3d480560125 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 7f1cc5ec23f..2671cee8dd3 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 243c40c21d7..121fd54abff 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 0b2476b3304..f164df89b7d 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 52058fa5a73..6fec2fd48ff 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],
+      ],
+    ]);
+  });
+});
-- 
GitLab