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() {
/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
......
......@@ -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}
......
......@@ -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 => {
......
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";
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;
......
......@@ -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;
......
......@@ -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),
};
};
......@@ -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,
);
......
......@@ -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",
);
......
......@@ -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,
};
});
};
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],
],
]);
});
});
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