Skip to content
Snippets Groups Projects
Unverified Commit ce4d4e67 authored by Alexander Lesnenko's avatar Alexander Lesnenko Committed by GitHub
Browse files

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
parent bcec5023
No related branches found
No related tags found
No related merge requests found
Showing
with 294 additions and 14 deletions
...@@ -17,6 +17,7 @@ export default function StaticVizPage() { ...@@ -17,6 +17,7 @@ export default function StaticVizPage() {
/static-viz/ and see the effects. You might need to hard refresh to /static-viz/ and see the effects. You might need to hard refresh to
see updates. see updates.
</Text> </Text>
<Box py={3}> <Box py={3}>
<Subhead>Line chart with timeseries data</Subhead> <Subhead>Line chart with timeseries data</Subhead>
<StaticChart <StaticChart
...@@ -595,6 +596,92 @@ export default function StaticVizPage() { ...@@ -595,6 +596,92 @@ export default function StaticVizPage() {
/> />
</Box> </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}> <Box py={3}>
<Subhead>Funnel</Subhead> <Subhead>Funnel</Subhead>
<StaticChart <StaticChart
......
...@@ -9,6 +9,7 @@ import { ...@@ -9,6 +9,7 @@ import {
Series, Series,
ChartSettings, ChartSettings,
ChartStyle, ChartStyle,
HydratedSeries,
} from "metabase/static-viz/components/XYChart/types"; } from "metabase/static-viz/components/XYChart/types";
import { LineSeries } from "metabase/static-viz/components/XYChart/shapes/LineSeries"; import { LineSeries } from "metabase/static-viz/components/XYChart/shapes/LineSeries";
import { BarSeries } from "metabase/static-viz/components/XYChart/shapes/BarSeries"; import { BarSeries } from "metabase/static-viz/components/XYChart/shapes/BarSeries";
...@@ -31,6 +32,7 @@ import { ...@@ -31,6 +32,7 @@ import {
calculateYDomains, calculateYDomains,
sortSeries, sortSeries,
getLegendColumns, getLegendColumns,
calculateStackedItems,
} from "metabase/static-viz/components/XYChart/utils"; } from "metabase/static-viz/components/XYChart/utils";
import { GoalLine } from "metabase/static-viz/components/XYChart/GoalLine"; import { GoalLine } from "metabase/static-viz/components/XYChart/GoalLine";
...@@ -45,11 +47,16 @@ export interface XYChartProps { ...@@ -45,11 +47,16 @@ export interface XYChartProps {
export const XYChart = ({ export const XYChart = ({
width, width,
height, height,
series, series: originalSeries,
settings, settings,
style, style,
}: XYChartProps) => { }: 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 yDomains = calculateYDomains(series, settings.goal?.value);
const yTickWidths = getYTickWidths( const yTickWidths = getYTickWidths(
settings.y.format, settings.y.format,
...@@ -70,6 +77,7 @@ export const XYChart = ({ ...@@ -70,6 +77,7 @@ export const XYChart = ({
yTickWidths.left, yTickWidths.left,
yTickWidths.right, yTickWidths.right,
xTicksDimensions.height, xTicksDimensions.height,
xTicksDimensions.width,
settings.labels, settings.labels,
style.axes.ticks.fontSize, style.axes.ticks.fontSize,
!!settings.goal, !!settings.goal,
...@@ -148,6 +156,7 @@ export const XYChart = ({ ...@@ -148,6 +156,7 @@ export const XYChart = ({
yScaleLeft={yScaleLeft} yScaleLeft={yScaleLeft}
yScaleRight={yScaleRight} yScaleRight={yScaleRight}
xAccessor={xScale.lineAccessor} xAccessor={xScale.lineAccessor}
areStacked={settings.stacking === "stack"}
/> />
<LineSeries <LineSeries
series={lines} series={lines}
......
...@@ -7,12 +7,14 @@ import { ...@@ -7,12 +7,14 @@ import {
SeriesDatum, SeriesDatum,
} from "metabase/static-viz/components/XYChart/types"; } from "metabase/static-viz/components/XYChart/types";
import { getY } from "metabase/static-viz/components/XYChart/utils"; import { getY } from "metabase/static-viz/components/XYChart/utils";
import { AreaSeriesStacked } from "./AreaSeriesStacked";
interface AreaSeriesProps { interface AreaSeriesProps {
series: Series[]; series: Series[];
yScaleLeft: PositionScale | null; yScaleLeft: PositionScale | null;
yScaleRight: PositionScale | null; yScaleRight: PositionScale | null;
xAccessor: (datum: SeriesDatum) => number; xAccessor: (datum: SeriesDatum) => number;
areStacked?: boolean;
} }
export const AreaSeries = ({ export const AreaSeries = ({
...@@ -20,7 +22,20 @@ export const AreaSeries = ({ ...@@ -20,7 +22,20 @@ export const AreaSeries = ({
yScaleLeft, yScaleLeft,
yScaleRight, yScaleRight,
xAccessor, xAccessor,
areStacked,
}: AreaSeriesProps) => { }: 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 ( return (
<Group> <Group>
{series.map(s => { {series.map(s => {
......
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>
);
};
...@@ -4,8 +4,8 @@ import { AccessorForArrayItem, PositionScale } from "@visx/shape/lib/types"; ...@@ -4,8 +4,8 @@ import { AccessorForArrayItem, PositionScale } from "@visx/shape/lib/types";
interface AreaProps<Datum> { interface AreaProps<Datum> {
x: AccessorForArrayItem<Datum, number>; x: AccessorForArrayItem<Datum, number>;
y: AccessorForArrayItem<Datum, number>; y: number | AccessorForArrayItem<Datum, number>;
y1: number; y1: number | AccessorForArrayItem<Datum, number>;
yScale: PositionScale; yScale: PositionScale;
data: Datum[]; data: Datum[];
color: string; color: string;
......
...@@ -24,9 +24,17 @@ export type Series = { ...@@ -24,9 +24,17 @@ export type Series = {
yAxisPosition: YAxisPosition; yAxisPosition: YAxisPosition;
}; };
export type StackedDatum = [XValue, YValue, YValue];
export type HydratedSeries = Series & {
stackedData?: StackedDatum[];
};
type TickDisplay = "show" | "hide" | "rotate-45"; type TickDisplay = "show" | "hide" | "rotate-45";
type Stacking = "stack" | "none";
export type ChartSettings = { export type ChartSettings = {
stacking?: Stacking;
x: { x: {
type: XAxisType; type: XAxisType;
tick_display?: TickDisplay; tick_display?: TickDisplay;
......
...@@ -8,6 +8,7 @@ export const GOAL_MARGIN = 6; ...@@ -8,6 +8,7 @@ export const GOAL_MARGIN = 6;
const calculateSideMargin = ( const calculateSideMargin = (
tickSpace: number, tickSpace: number,
labelFontSize: number, labelFontSize: number,
minMargin: number,
label?: string, label?: string,
) => { ) => {
let margin = CHART_PADDING + tickSpace; let margin = CHART_PADDING + tickSpace;
...@@ -16,21 +17,34 @@ const calculateSideMargin = ( ...@@ -16,21 +17,34 @@ const calculateSideMargin = (
margin += measureTextHeight(labelFontSize) + LABEL_OFFSET; margin += measureTextHeight(labelFontSize) + LABEL_OFFSET;
} }
return margin; return Math.max(margin, minMargin);
}; };
export const calculateMargin = ( export const calculateMargin = (
leftYTickWidth: number, leftYTickWidth: number,
rightYTickWidth: number, rightYTickWidth: number,
xTickHeight: number, xTickHeight: number,
xTickWidth: number,
labels: ChartSettings["labels"], labels: ChartSettings["labels"],
labelFontSize: number, labelFontSize: number,
hasGoalLine?: boolean, hasGoalLine?: boolean,
) => { ) => {
const minHorizontalMargin = xTickWidth / 2;
return { return {
top: hasGoalLine ? GOAL_MARGIN + CHART_PADDING : CHART_PADDING, top: hasGoalLine ? GOAL_MARGIN + CHART_PADDING : CHART_PADDING,
left: calculateSideMargin(leftYTickWidth, labelFontSize, labels.left), left: calculateSideMargin(
right: calculateSideMargin(rightYTickWidth, labelFontSize, labels.right), leftYTickWidth,
bottom: calculateSideMargin(xTickHeight, labelFontSize, labels.bottom), labelFontSize,
minHorizontalMargin,
labels.left,
),
right: calculateSideMargin(
rightYTickWidth,
labelFontSize,
minHorizontalMargin,
labels.right,
),
bottom: calculateSideMargin(xTickHeight, labelFontSize, 0, labels.bottom),
}; };
}; };
...@@ -12,6 +12,7 @@ describe("calculateMargin", () => { ...@@ -12,6 +12,7 @@ describe("calculateMargin", () => {
const leftYTickWidth = 100; const leftYTickWidth = 100;
const rightYTickWidth = 200; const rightYTickWidth = 200;
const xTickHeight = 300; const xTickHeight = 300;
const xTickWidth = 20;
const labelFontSize = 11; const labelFontSize = 11;
const labels = {}; const labels = {};
...@@ -20,6 +21,7 @@ describe("calculateMargin", () => { ...@@ -20,6 +21,7 @@ describe("calculateMargin", () => {
leftYTickWidth, leftYTickWidth,
rightYTickWidth, rightYTickWidth,
xTickHeight, xTickHeight,
xTickWidth,
labels, labels,
labelFontSize, labelFontSize,
); );
...@@ -34,6 +36,7 @@ describe("calculateMargin", () => { ...@@ -34,6 +36,7 @@ describe("calculateMargin", () => {
const leftYTickWidth = 100; const leftYTickWidth = 100;
const rightYTickWidth = 200; const rightYTickWidth = 200;
const xTickHeight = 300; const xTickHeight = 300;
const xTickWidth = 20;
const labelFontSize = 11; const labelFontSize = 11;
const labels = { left: "left label", right: "right label" }; const labels = { left: "left label", right: "right label" };
...@@ -42,6 +45,7 @@ describe("calculateMargin", () => { ...@@ -42,6 +45,7 @@ describe("calculateMargin", () => {
leftYTickWidth, leftYTickWidth,
rightYTickWidth, rightYTickWidth,
xTickHeight, xTickHeight,
xTickWidth,
labels, labels,
labelFontSize, labelFontSize,
); );
......
...@@ -13,6 +13,8 @@ import { ...@@ -13,6 +13,8 @@ import {
Range, Range,
Series, Series,
YAxisType, YAxisType,
HydratedSeries,
StackedDatum,
} from "metabase/static-viz/components/XYChart/types"; } from "metabase/static-viz/components/XYChart/types";
import { import {
getX, getX,
...@@ -85,11 +87,13 @@ export const createXScale = ( ...@@ -85,11 +87,13 @@ export const createXScale = (
}; };
const calculateYDomain = ( const calculateYDomain = (
series: Series[], series: HydratedSeries[],
goalValue?: number, goalValue?: number,
): ContiniousDomain => { ): ContiniousDomain => {
const values = series const values = series
.flatMap(series => series.data) .flatMap<SeriesDatum | StackedDatum>(
series => series.stackedData ?? series.data,
)
.map(datum => getY(datum)); .map(datum => getY(datum));
const minValue = min(values); const minValue = min(values);
const maxValue = max(values); const maxValue = max(values);
...@@ -100,7 +104,10 @@ const calculateYDomain = ( ...@@ -100,7 +104,10 @@ const calculateYDomain = (
]; ];
}; };
export const calculateYDomains = (series: Series[], goalValue?: number) => { export const calculateYDomains = (
series: HydratedSeries[],
goalValue?: number,
) => {
const leftScaleSeries = series.filter( const leftScaleSeries = series.filter(
series => series.yAxisPosition === "left", series => series.yAxisPosition === "left",
); );
......
...@@ -3,10 +3,13 @@ import { ...@@ -3,10 +3,13 @@ import {
Series, Series,
SeriesDatum, SeriesDatum,
XAxisType, XAxisType,
StackedDatum,
} from "metabase/static-viz/components/XYChart/types"; } from "metabase/static-viz/components/XYChart/types";
export const getX = (d: SeriesDatum) => d[0]; export const getX = (d: SeriesDatum | StackedDatum) => d[0];
export const getY = (d: SeriesDatum) => d[1]; export const getY = (d: SeriesDatum | StackedDatum) => d[1];
export const getY1 = (d: StackedDatum) => d[2];
export const partitionByYAxis = (series: Series[]) => { export const partitionByYAxis = (series: Series[]) => {
return _.partition( return _.partition(
...@@ -38,3 +41,31 @@ export const sortSeries = (series: Series[], type: XAxisType) => { ...@@ -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,
};
});
};
import { sortSeries } from "./series"; import { Series } from "../types";
import { calculateStackedItems, sortSeries } from "./series";
describe("sortSeries", () => { describe("sortSeries", () => {
it("sorts timeseries data", () => { it("sorts timeseries data", () => {
...@@ -100,3 +101,68 @@ describe("sortSeries", () => { ...@@ -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],
],
]);
});
});
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