diff --git a/frontend/src/metabase/internal/pages/StaticVizPage.jsx b/frontend/src/metabase/internal/pages/StaticVizPage.jsx index 1dc03e51fa1fafed3e87f169c3f5a2a40dd640db..8c986c56c0047040d0abb05d9661a06578765830 100644 --- a/frontend/src/metabase/internal/pages/StaticVizPage.jsx +++ b/frontend/src/metabase/internal/pages/StaticVizPage.jsx @@ -17,7 +17,181 @@ 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 + type="timeseries/line" + options={{ + data: [ + ["2020-01-10", 10], + ["2020-06-10", 60], + ["2020-12-10", 80], + ], + accessors: { + x: row => new Date(row[0]).valueOf(), + y: row => row[1], + }, + labels: { + left: "Count", + bottom: "Created At", + }, + }} + /> + </Box> + <Box py={3}> + <Subhead>Area chart with timeseries data</Subhead> + <StaticChart + type="timeseries/area" + options={{ + data: [ + ["2020-01-10", 10], + ["2020-06-10", 60], + ["2020-12-10", 80], + ], + accessors: { + x: row => new Date(row[0]).valueOf(), + y: row => row[1], + }, + settings: { + x: { + date_style: "MMM", + }, + }, + labels: { + left: "Count", + bottom: "Created At", + }, + colors: { + brand: "#88BF4D", + }, + }} + /> + </Box> + <Box py={3}> + <Subhead>Bar chart with timeseries data</Subhead> + <StaticChart + type="timeseries/bar" + options={{ + data: [ + ["2020-10-21", 20], + ["2020-10-22", 30], + ["2020-10-23", 25], + ["2020-10-24", 10], + ["2020-10-25", 15], + ], + accessors: { + x: row => new Date(row[0]).valueOf(), + y: row => row[1], + }, + settings: { + x: { + date_style: "MM/DD/YYYY", + }, + y: { + number_style: "currency", + currency: "USD", + currency_style: "symbol", + decimals: 0, + }, + }, + labels: { + left: "Price", + bottom: "Created At", + }, + }} + /> + </Box> + <Box py={3}> + <Subhead>Line chart with categorical data</Subhead> + <StaticChart + type="categorical/line" + options={{ + data: [ + ["Alden Sparks", 70], + ["Areli Guerra", 30], + ["Arturo Hopkins", 80], + ["Beatrice Lane", 120], + ["Brylee Davenport", 100], + ["Cali Nixon", 60], + ["Dane Terrell", 150], + ["Deshawn Rollins", 40], + ["Isabell Bright", 70], + ["Kaya Rowe", 20], + ["Roderick Herman", 50], + ["Ruth Dougherty", 75], + ], + accessors: { + x: row => row[0], + y: row => row[1], + }, + labels: { + left: "Tasks", + bottom: "People", + }, + }} + /> + </Box> + <Box py={3}> + <Subhead>Area chart with categorical data</Subhead> + <StaticChart + type="categorical/area" + options={{ + data: [ + ["Alden Sparks", 70], + ["Areli Guerra", 30], + ["Arturo Hopkins", 80], + ["Beatrice Lane", 120], + ["Brylee Davenport", 100], + ["Cali Nixon", 60], + ["Dane Terrell", 150], + ["Deshawn Rollins", 40], + ["Isabell Bright", 70], + ["Kaya Rowe", 20], + ["Roderick Herman", 50], + ["Ruth Dougherty", 75], + ], + accessors: { + x: row => row[0], + y: row => row[1], + }, + labels: { + left: "Tasks", + bottom: "People", + }, + }} + /> + </Box> + <Box py={3}> + <Subhead>Bar chart with categorical data</Subhead> + <StaticChart + type="categorical/bar" + options={{ + data: [ + ["Alden Sparks", 70], + ["Areli Guerra", 30], + ["Arturo Hopkins", 80], + ["Beatrice Lane", 120], + ["Brylee Davenport", 100], + ["Cali Nixon", 60], + ["Dane Terrell", 150], + ["Deshawn Rollins", 40], + ["Isabell Bright", 70], + ["Kaya Rowe", 20], + ["Roderick Herman", 50], + ["Ruth Dougherty", 75], + ], + accessors: { + x: row => row[0], + y: row => row[1], + }, + labels: { + left: "Tasks", + bottom: "People", + }, + }} + /> + </Box> <Box py={3}> <Subhead>Donut chart with categorical data</Subhead> <StaticChart diff --git a/frontend/src/metabase/static-viz/components/CategoricalAreaChart/CategoricalAreaChart.jsx b/frontend/src/metabase/static-viz/components/CategoricalAreaChart/CategoricalAreaChart.jsx new file mode 100644 index 0000000000000000000000000000000000000000..d5df42bd7b48a765b6c895ba861d1c20a7616729 --- /dev/null +++ b/frontend/src/metabase/static-viz/components/CategoricalAreaChart/CategoricalAreaChart.jsx @@ -0,0 +1,163 @@ +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 { AreaClosed, LinePath } from "@visx/shape"; +import { Text } from "@visx/text"; +import { + getXTickWidth, + getXTickLabelProps, + getYTickLabelProps, + getYTickWidth, + getRotatedXTickHeight, + getLabelProps, +} from "../../lib/axes"; +import { formatNumber } from "../../lib/numbers"; +import { truncateText } from "../../lib/text"; + +const propTypes = { + data: PropTypes.array.isRequired, + accessors: PropTypes.shape({ + x: PropTypes.func.isRequired, + y: PropTypes.func.isRequired, + }).isRequired, + settings: PropTypes.shape({ + x: PropTypes.object, + y: PropTypes.object, + colors: PropTypes.object, + }), + labels: PropTypes.shape({ + left: PropTypes.string, + bottom: PropTypes.string, + }), +}; + +const layout = { + width: 540, + height: 300, + margin: { + top: 0, + left: 55, + right: 40, + bottom: 40, + }, + font: { + size: 11, + family: "Lato, sans-serif", + }, + colors: { + brand: "#509ee3", + textLight: "#b8bbc3", + textMedium: "#949aab", + }, + barPadding: 0.2, + labelFontWeight: 700, + labelPadding: 12, + maxTickWidth: 100, + areaOpacity: 0.2, + strokeDasharray: "4", +}; + +const CategoricalAreaChart = ({ data, accessors, settings, labels }) => { + const colors = settings?.colors; + const isVertical = data.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; + const yMin = isVertical ? xLabelOffset : layout.margin.bottom; + const yMax = layout.height - yMin; + const innerWidth = xMax - xMin; + const textBaseline = Math.floor(layout.font.size / 2); + const leftLabel = labels?.left; + const bottomLabel = !isVertical ? labels?.bottom : undefined; + const palette = { ...layout.colors, ...colors }; + + const xScale = scaleBand({ + domain: data.map(accessors.x), + range: [xMin, xMax], + round: true, + padding: layout.barPadding, + }); + + const yScale = scaleLinear({ + domain: [0, Math.max(...data.map(accessors.y))], + range: [yMax, 0], + nice: true, + }); + + 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}> + <GridRows + scale={yScale} + left={xMin} + width={innerWidth} + strokeDasharray={layout.strokeDasharray} + /> + <AreaClosed + data={data} + yScale={yScale} + fill={palette.brand} + opacity={layout.areaOpacity} + x={d => xScale(accessors.x(d)) + xScale.bandwidth() / 2} + y={d => yScale(accessors.y(d))} + /> + <AxisLeft + scale={yScale} + left={xMin} + label={leftLabel} + labelOffset={yLabelOffset} + hideTicks + hideAxisLine + labelProps={getLabelProps(layout)} + tickFormat={value => formatNumber(value, settings?.y)} + tickLabelProps={() => getYTickLabelProps(layout)} + /> + <LinePath + data={data} + stroke={palette.brand} + strokeWidth={layout.strokeWidth} + x={d => xScale(accessors.x(d)) + xScale.bandwidth() / 2} + y={d => yScale(accessors.y(d))} + /> + <AxisBottom + scale={xScale} + top={yMax} + label={bottomLabel} + numTicks={data.length} + stroke={palette.textLight} + tickStroke={palette.textLight} + labelProps={getLabelProps(layout)} + tickComponent={props => <Text {...getXTickProps(props)} />} + tickLabelProps={() => getXTickLabelProps(layout, isVertical)} + /> + </svg> + ); +}; + +CategoricalAreaChart.propTypes = propTypes; + +export default CategoricalAreaChart; diff --git a/frontend/src/metabase/static-viz/components/CategoricalAreaChart/index.js b/frontend/src/metabase/static-viz/components/CategoricalAreaChart/index.js new file mode 100644 index 0000000000000000000000000000000000000000..f57673cb6a5794ded8347e5090413b7be93e9905 --- /dev/null +++ b/frontend/src/metabase/static-viz/components/CategoricalAreaChart/index.js @@ -0,0 +1 @@ +export { default } from "./CategoricalAreaChart"; diff --git a/frontend/src/metabase/static-viz/components/CategoricalBarChart/CategoricalBarChart.jsx b/frontend/src/metabase/static-viz/components/CategoricalBarChart/CategoricalBarChart.jsx new file mode 100644 index 0000000000000000000000000000000000000000..bcd586c60dff2c911d153283aa494d427fd2306a --- /dev/null +++ b/frontend/src/metabase/static-viz/components/CategoricalBarChart/CategoricalBarChart.jsx @@ -0,0 +1,160 @@ +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 { Text } from "@visx/text"; +import { + getXTickWidth, + getXTickLabelProps, + getYTickLabelProps, + getYTickWidth, + getRotatedXTickHeight, + getLabelProps, +} from "../../lib/axes"; +import { formatNumber } from "../../lib/numbers"; +import { truncateText } from "../../lib/text"; + +const propTypes = { + data: PropTypes.array.isRequired, + accessors: PropTypes.shape({ + x: PropTypes.func.isRequired, + y: PropTypes.func.isRequired, + }).isRequired, + settings: PropTypes.shape({ + x: PropTypes.object, + y: PropTypes.object, + colors: PropTypes.object, + }), + labels: PropTypes.shape({ + left: PropTypes.string, + bottom: PropTypes.string, + }), +}; + +const layout = { + width: 540, + height: 300, + margin: { + top: 0, + left: 55, + right: 40, + bottom: 40, + }, + font: { + size: 11, + family: "Lato, sans-serif", + }, + colors: { + brand: "#509ee3", + textLight: "#b8bbc3", + textMedium: "#949aab", + }, + barPadding: 0.2, + labelFontWeight: 700, + labelPadding: 12, + maxTickWidth: 100, + strokeDasharray: "4", +}; + +const CategoricalBarChart = ({ data, accessors, settings, labels }) => { + const colors = settings?.colors; + const isVertical = data.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; + const yMin = isVertical ? xLabelOffset : layout.margin.bottom; + const yMax = layout.height - yMin; + const innerWidth = xMax - xMin; + const innerHeight = yMax - layout.margin.top; + const textBaseline = Math.floor(layout.font.size / 2); + const leftLabel = labels?.left; + const bottomLabel = !isVertical ? labels?.bottom : undefined; + const palette = { ...layout.colors, ...colors }; + + const xScale = scaleBand({ + domain: data.map(accessors.x), + range: [xMin, xMax], + round: true, + padding: layout.barPadding, + }); + + const yScale = scaleLinear({ + domain: [0, Math.max(...data.map(accessors.y))], + range: [yMax, 0], + nice: true, + }); + + const getBarProps = d => { + const width = xScale.bandwidth(); + const height = innerHeight - yScale(accessors.y(d)); + const x = xScale(accessors.x(d)); + const y = yMax - height; + + return { x, y, width, height, fill: palette.brand }; + }; + + 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}> + <GridRows + scale={yScale} + left={xMin} + width={innerWidth} + strokeDasharray={layout.strokeDasharray} + /> + {data.map((d, index) => ( + <Bar key={index} {...getBarProps(d)} /> + ))} + <AxisLeft + scale={yScale} + left={xMin} + label={leftLabel} + labelOffset={yLabelOffset} + hideTicks + hideAxisLine + labelProps={getLabelProps(layout)} + tickFormat={value => formatNumber(value, settings?.y)} + tickLabelProps={() => getYTickLabelProps(layout)} + /> + <AxisBottom + scale={xScale} + top={yMax} + label={bottomLabel} + numTicks={data.length} + stroke={palette.textLight} + tickStroke={palette.textLight} + labelProps={getLabelProps(layout)} + tickComponent={props => <Text {...getXTickProps(props)} />} + tickLabelProps={() => getXTickLabelProps(layout, isVertical)} + /> + </svg> + ); +}; + +CategoricalBarChart.propTypes = propTypes; + +export default CategoricalBarChart; diff --git a/frontend/src/metabase/static-viz/components/CategoricalBarChart/index.js b/frontend/src/metabase/static-viz/components/CategoricalBarChart/index.js new file mode 100644 index 0000000000000000000000000000000000000000..74a0f3cb5bcf04aa23becf9afa26ea01741fbf4b --- /dev/null +++ b/frontend/src/metabase/static-viz/components/CategoricalBarChart/index.js @@ -0,0 +1 @@ +export { default } from "./CategoricalBarChart"; diff --git a/frontend/src/metabase/static-viz/components/CategoricalLineChart/CategoricalLineChart.jsx b/frontend/src/metabase/static-viz/components/CategoricalLineChart/CategoricalLineChart.jsx new file mode 100644 index 0000000000000000000000000000000000000000..feff35025dd3bd79d1b193f9c31d3f6f4ed5a534 --- /dev/null +++ b/frontend/src/metabase/static-viz/components/CategoricalLineChart/CategoricalLineChart.jsx @@ -0,0 +1,154 @@ +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 { LinePath } from "@visx/shape"; +import { Text } from "@visx/text"; +import { + getXTickWidth, + getXTickLabelProps, + getYTickLabelProps, + getYTickWidth, + getRotatedXTickHeight, + getLabelProps, +} from "../../lib/axes"; +import { formatNumber } from "../../lib/numbers"; +import { truncateText } from "../../lib/text"; + +const propTypes = { + data: PropTypes.array.isRequired, + accessors: PropTypes.shape({ + x: PropTypes.func.isRequired, + y: PropTypes.func.isRequired, + }).isRequired, + settings: PropTypes.shape({ + x: PropTypes.object, + y: PropTypes.object, + colors: PropTypes.object, + }), + labels: PropTypes.shape({ + left: PropTypes.string, + bottom: PropTypes.string, + }), +}; + +const layout = { + width: 540, + height: 300, + margin: { + top: 0, + left: 55, + right: 40, + bottom: 40, + }, + font: { + size: 11, + family: "Lato, sans-serif", + }, + colors: { + brand: "#509ee3", + textLight: "#b8bbc3", + textMedium: "#949aab", + }, + barPadding: 0.2, + labelFontWeight: 700, + labelPadding: 12, + maxTickWidth: 100, + strokeDasharray: "4", +}; + +const CategoricalLineChart = ({ data, accessors, settings, labels }) => { + const colors = settings?.colors; + const isVertical = data.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; + const yMin = isVertical ? xLabelOffset : layout.margin.bottom; + const yMax = layout.height - yMin; + const innerWidth = xMax - xMin; + const textBaseline = Math.floor(layout.font.size / 2); + const leftLabel = labels?.left; + const bottomLabel = !isVertical ? labels?.bottom : undefined; + const palette = { ...layout.colors, ...colors }; + + const xScale = scaleBand({ + domain: data.map(accessors.x), + range: [xMin, xMax], + round: true, + padding: layout.barPadding, + }); + + const yScale = scaleLinear({ + domain: [0, Math.max(...data.map(accessors.y))], + range: [yMax, 0], + nice: true, + }); + + 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}> + <GridRows + scale={yScale} + left={xMin} + width={innerWidth} + strokeDasharray={layout.strokeDasharray} + /> + <LinePath + data={data} + stroke={palette.brand} + strokeWidth={layout.strokeWidth} + x={d => xScale(accessors.x(d)) + xScale.bandwidth() / 2} + y={d => yScale(accessors.y(d))} + /> + <AxisLeft + scale={yScale} + left={xMin} + label={leftLabel} + labelOffset={yLabelOffset} + hideTicks + hideAxisLine + labelProps={getLabelProps(layout)} + tickFormat={value => formatNumber(value, settings?.y)} + tickLabelProps={() => getYTickLabelProps(layout)} + /> + <AxisBottom + scale={xScale} + top={yMax} + label={bottomLabel} + numTicks={data.length} + stroke={palette.textLight} + tickStroke={palette.textLight} + labelProps={getLabelProps(layout)} + tickComponent={props => <Text {...getXTickProps(props)} />} + tickLabelProps={() => getXTickLabelProps(layout, isVertical)} + /> + </svg> + ); +}; + +CategoricalLineChart.propTypes = propTypes; + +export default CategoricalLineChart; diff --git a/frontend/src/metabase/static-viz/components/CategoricalLineChart/index.js b/frontend/src/metabase/static-viz/components/CategoricalLineChart/index.js new file mode 100644 index 0000000000000000000000000000000000000000..c6ab45b493ba70c1456a68f583d756115b8d988b --- /dev/null +++ b/frontend/src/metabase/static-viz/components/CategoricalLineChart/index.js @@ -0,0 +1 @@ +export { default } from "./CategoricalLineChart"; diff --git a/frontend/src/metabase/static-viz/components/TimeSeriesAreaChart/TimeSeriesAreaChart.jsx b/frontend/src/metabase/static-viz/components/TimeSeriesAreaChart/TimeSeriesAreaChart.jsx new file mode 100644 index 0000000000000000000000000000000000000000..dab4072cf1cb0a61f085dcbd74255eb38c0c4184 --- /dev/null +++ b/frontend/src/metabase/static-viz/components/TimeSeriesAreaChart/TimeSeriesAreaChart.jsx @@ -0,0 +1,139 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { scaleLinear, scaleTime } from "@visx/scale"; +import { GridRows } from "@visx/grid"; +import { AxisBottom, AxisLeft } from "@visx/axis"; +import { AreaClosed, LinePath } from "@visx/shape"; +import { + getLabelProps, + getXTickLabelProps, + getYTickLabelProps, + getYTickWidth, +} from "../../lib/axes"; +import { formatDate } from "../../lib/dates"; +import { formatNumber } from "../../lib/numbers"; +import { sortTimeSeries } from "../../lib/sort"; + +const propTypes = { + data: PropTypes.array.isRequired, + accessors: PropTypes.shape({ + x: PropTypes.func, + y: PropTypes.func, + }).isRequired, + settings: PropTypes.shape({ + x: PropTypes.object, + y: PropTypes.object, + colors: PropTypes.object, + }), + labels: PropTypes.shape({ + left: PropTypes.string, + bottom: PropTypes.string, + }), +}; + +const layout = { + width: 540, + height: 300, + margin: { + top: 0, + left: 55, + right: 40, + bottom: 40, + }, + font: { + size: 11, + family: "Lato, sans-serif", + }, + colors: { + brand: "#509ee3", + brandLight: "#DDECFA", + textLight: "#b8bbc3", + textMedium: "#949aab", + }, + numTicks: 5, + strokeWidth: 2, + labelFontWeight: 700, + labelPadding: 12, + areaOpacity: 0.2, + strokeDasharray: "4", +}; + +const TimeSeriesAreaChart = ({ data, accessors, settings, labels }) => { + data = sortTimeSeries(data); + const colors = settings?.colors; + 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; + const yMax = layout.height - layout.margin.bottom; + const innerWidth = xMax - xMin; + const leftLabel = labels?.left; + const bottomLabel = labels?.bottom; + const palette = { ...layout.colors, ...colors }; + + const xScale = scaleTime({ + domain: [ + Math.min(...data.map(accessors.x)), + Math.max(...data.map(accessors.x)), + ], + range: [xMin, xMax], + }); + + const yScale = scaleLinear({ + domain: [0, Math.max(...data.map(accessors.y))], + range: [yMax, 0], + nice: true, + }); + + return ( + <svg width={layout.width} height={layout.height}> + <GridRows + scale={yScale} + left={xMin} + width={innerWidth} + strokeDasharray={layout.strokeDasharray} + /> + <AreaClosed + data={data} + yScale={yScale} + fill={palette.brand} + opacity={layout.areaOpacity} + x={d => xScale(accessors.x(d))} + y={d => yScale(accessors.y(d))} + /> + <LinePath + data={data} + stroke={palette.brand} + strokeWidth={layout.strokeWidth} + x={d => xScale(accessors.x(d))} + y={d => yScale(accessors.y(d))} + /> + <AxisLeft + scale={yScale} + left={xMin} + label={leftLabel} + labelOffset={yLabelOffset} + hideTicks + hideAxisLine + labelProps={getLabelProps(layout)} + tickFormat={value => formatNumber(value, settings?.y)} + tickLabelProps={() => getYTickLabelProps(layout)} + /> + <AxisBottom + scale={xScale} + top={yMax} + label={bottomLabel} + numTicks={layout.numTicks} + stroke={palette.textLight} + tickStroke={palette.textLight} + labelProps={getLabelProps(layout)} + tickFormat={value => formatDate(value, settings?.x)} + tickLabelProps={() => getXTickLabelProps(layout)} + /> + </svg> + ); +}; + +TimeSeriesAreaChart.propTypes = propTypes; + +export default TimeSeriesAreaChart; diff --git a/frontend/src/metabase/static-viz/components/TimeSeriesAreaChart/index.js b/frontend/src/metabase/static-viz/components/TimeSeriesAreaChart/index.js new file mode 100644 index 0000000000000000000000000000000000000000..97b1d21fca2e79a02f01faa17c661e4c35057c23 --- /dev/null +++ b/frontend/src/metabase/static-viz/components/TimeSeriesAreaChart/index.js @@ -0,0 +1 @@ +export { default } from "./TimeSeriesAreaChart"; diff --git a/frontend/src/metabase/static-viz/components/TimeSeriesBarChart/TimeSeriesBarChart.jsx b/frontend/src/metabase/static-viz/components/TimeSeriesBarChart/TimeSeriesBarChart.jsx new file mode 100644 index 0000000000000000000000000000000000000000..5ef188346ab458db0908dc227e84de5c33b43caf --- /dev/null +++ b/frontend/src/metabase/static-viz/components/TimeSeriesBarChart/TimeSeriesBarChart.jsx @@ -0,0 +1,134 @@ +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 { + getLabelProps, + getXTickLabelProps, + getYTickLabelProps, + getYTickWidth, +} from "../../lib/axes"; +import { formatDate } from "../../lib/dates"; +import { formatNumber } from "../../lib/numbers"; +import { sortTimeSeries } from "../../lib/sort"; + +const propTypes = { + data: PropTypes.array.isRequired, + accessors: PropTypes.shape({ + x: PropTypes.func.isRequired, + y: PropTypes.func.isRequired, + }).isRequired, + settings: PropTypes.shape({ + x: PropTypes.object, + y: PropTypes.object, + colors: PropTypes.object, + }), + labels: PropTypes.shape({ + left: PropTypes.string, + bottom: PropTypes.string, + }), +}; + +const layout = { + width: 540, + height: 300, + margin: { + top: 0, + left: 55, + right: 40, + bottom: 40, + }, + font: { + size: 11, + family: "Lato, sans-serif", + }, + colors: { + brand: "#509ee3", + textLight: "#b8bbc3", + textMedium: "#949aab", + }, + numTicks: 5, + barPadding: 0.2, + labelFontWeight: 700, + labelPadding: 12, + strokeDasharray: "4", +}; + +const TimeSeriesBarChart = ({ data, accessors, settings, labels }) => { + data = sortTimeSeries(data); + const colors = settings?.colors; + 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; + const yMax = layout.height - layout.margin.bottom; + const innerWidth = xMax - xMin; + const innerHeight = yMax - layout.margin.top; + const leftLabel = labels?.left; + const bottomLabel = labels?.bottom; + const palette = { ...layout.colors, ...colors }; + + const xScale = scaleBand({ + domain: data.map(accessors.x), + range: [xMin, xMax], + round: true, + padding: layout.barPadding, + }); + + const yScale = scaleLinear({ + domain: [0, Math.max(...data.map(accessors.y))], + range: [yMax, 0], + nice: true, + }); + + const getBarProps = d => { + const width = xScale.bandwidth(); + const height = innerHeight - yScale(accessors.y(d)); + const x = xScale(accessors.x(d)); + const y = yMax - height; + + return { x, y, width, height, fill: palette.brand }; + }; + + return ( + <svg width={layout.width} height={layout.height}> + <GridRows + scale={yScale} + left={xMin} + width={innerWidth} + strokeDasharray={layout.strokeDasharray} + /> + {data.map((d, index) => ( + <Bar key={index} {...getBarProps(d)} /> + ))} + <AxisLeft + scale={yScale} + left={xMin} + label={leftLabel} + labelOffset={yLabelOffset} + hideTicks + hideAxisLine + labelProps={getLabelProps(layout)} + tickFormat={value => formatNumber(value, settings?.y)} + tickLabelProps={() => getYTickLabelProps(layout)} + /> + <AxisBottom + scale={xScale} + top={yMax} + label={bottomLabel} + numTicks={layout.numTicks} + stroke={palette.textLight} + tickStroke={palette.textLight} + labelProps={getLabelProps(layout)} + tickFormat={value => formatDate(value, settings?.x)} + tickLabelProps={() => getXTickLabelProps(layout)} + /> + </svg> + ); +}; + +TimeSeriesBarChart.propTypes = propTypes; + +export default TimeSeriesBarChart; diff --git a/frontend/src/metabase/static-viz/components/TimeSeriesBarChart/index.js b/frontend/src/metabase/static-viz/components/TimeSeriesBarChart/index.js new file mode 100644 index 0000000000000000000000000000000000000000..b105a05e679569c5abadb5b391442481800caca6 --- /dev/null +++ b/frontend/src/metabase/static-viz/components/TimeSeriesBarChart/index.js @@ -0,0 +1 @@ +export { default } from "./TimeSeriesBarChart"; diff --git a/frontend/src/metabase/static-viz/components/TimeSeriesLineChart/TimeSeriesLineChart.jsx b/frontend/src/metabase/static-viz/components/TimeSeriesLineChart/TimeSeriesLineChart.jsx new file mode 100644 index 0000000000000000000000000000000000000000..eb3dfdc50afe246ec8bb872a2dec766d001a2316 --- /dev/null +++ b/frontend/src/metabase/static-viz/components/TimeSeriesLineChart/TimeSeriesLineChart.jsx @@ -0,0 +1,129 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { scaleLinear, scaleTime } from "@visx/scale"; +import { GridRows } from "@visx/grid"; +import { AxisBottom, AxisLeft } from "@visx/axis"; +import { LinePath } from "@visx/shape"; +import { + getXTickLabelProps, + getYTickWidth, + getYTickLabelProps, + getLabelProps, +} from "../../lib/axes"; +import { formatDate } from "../../lib/dates"; +import { formatNumber } from "../../lib/numbers"; +import { sortTimeSeries } from "../../lib/sort"; + +const propTypes = { + data: PropTypes.array.isRequired, + accessors: PropTypes.shape({ + x: PropTypes.func, + y: PropTypes.func, + }).isRequired, + settings: PropTypes.shape({ + x: PropTypes.object, + y: PropTypes.object, + colors: PropTypes.object, + }), + labels: PropTypes.shape({ + left: PropTypes.string, + bottom: PropTypes.string, + }), +}; + +const layout = { + width: 540, + height: 300, + margin: { + top: 0, + left: 55, + right: 40, + bottom: 40, + }, + font: { + size: 11, + family: "Lato, sans-serif", + }, + colors: { + brand: "#509ee3", + textLight: "#b8bbc3", + textMedium: "#949aab", + }, + numTicks: 5, + labelFontWeight: 700, + labelPadding: 12, + strokeWidth: 2, + strokeDasharray: "4", +}; + +const TimeSeriesLineChart = ({ data, accessors, settings, labels }) => { + data = sortTimeSeries(data); + const colors = settings?.colors; + 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; + const yMax = layout.height - layout.margin.bottom; + const innerWidth = xMax - xMin; + const leftLabel = labels?.left; + const bottomLabel = labels?.bottom; + const palette = { ...layout.colors, ...colors }; + + const xScale = scaleTime({ + domain: [ + Math.min(...data.map(accessors.x)), + Math.max(...data.map(accessors.x)), + ], + range: [xMin, xMax], + }); + + const yScale = scaleLinear({ + domain: [0, Math.max(...data.map(accessors.y))], + range: [yMax, 0], + nice: true, + }); + + return ( + <svg width={layout.width} height={layout.height}> + <GridRows + scale={yScale} + left={xMin} + width={innerWidth} + strokeDasharray={layout.strokeDasharray} + /> + <LinePath + data={data} + stroke={palette.brand} + strokeWidth={layout.strokeWidth} + x={d => xScale(accessors.x(d))} + y={d => yScale(accessors.y(d))} + /> + <AxisLeft + scale={yScale} + left={xMin} + label={leftLabel} + labelOffset={yLabelOffset} + hideTicks + hideAxisLine + labelProps={getLabelProps(layout)} + tickFormat={value => formatNumber(value, settings?.y)} + tickLabelProps={() => getYTickLabelProps(layout)} + /> + <AxisBottom + scale={xScale} + top={yMax} + label={bottomLabel} + numTicks={layout.numTicks} + stroke={palette.textLight} + tickStroke={palette.textLight} + labelProps={getLabelProps(layout)} + tickFormat={value => formatDate(value, settings?.x)} + tickLabelProps={() => getXTickLabelProps(layout)} + /> + </svg> + ); +}; + +TimeSeriesLineChart.propTypes = propTypes; + +export default TimeSeriesLineChart; diff --git a/frontend/src/metabase/static-viz/components/TimeSeriesLineChart/index.js b/frontend/src/metabase/static-viz/components/TimeSeriesLineChart/index.js new file mode 100644 index 0000000000000000000000000000000000000000..d23115fed8d48444c9258a97b9bb5f2011290cfa --- /dev/null +++ b/frontend/src/metabase/static-viz/components/TimeSeriesLineChart/index.js @@ -0,0 +1 @@ +export { default } from "./TimeSeriesLineChart"; diff --git a/frontend/src/metabase/static-viz/containers/StaticChart/StaticChart.jsx b/frontend/src/metabase/static-viz/containers/StaticChart/StaticChart.jsx index 8722e4840f1e1f57dac7d5246e90c8a1aa9c8b6e..742d84aae226d14dff52d928c392415df53d4447 100644 --- a/frontend/src/metabase/static-viz/containers/StaticChart/StaticChart.jsx +++ b/frontend/src/metabase/static-viz/containers/StaticChart/StaticChart.jsx @@ -1,7 +1,13 @@ import React from "react"; import PropTypes from "prop-types"; +import CategoricalAreaChart from "../../components/CategoricalAreaChart"; +import CategoricalBarChart from "../../components/CategoricalBarChart"; import CategoricalDonutChart from "../../components/CategoricalDonutChart"; +import CategoricalLineChart from "../../components/CategoricalLineChart"; import CategoricalWaterfallChart from "../../components/CategoricalWaterfallChart"; +import TimeSeriesAreaChart from "../../components/TimeSeriesAreaChart"; +import TimeSeriesBarChart from "../../components/TimeSeriesBarChart"; +import TimeSeriesLineChart from "../../components/TimeSeriesLineChart"; import ProgressBar from "../../components/ProgressBar"; import Funnel from "../../components/FunnelChart"; import TimeSeriesWaterfallChart from "../../components/TimeSeriesWaterfallChart"; @@ -9,8 +15,14 @@ import LineAreaBarChart from "../../components/LineAreaBarChart"; const propTypes = { type: PropTypes.oneOf([ + "categorical/area", + "categorical/bar", "categorical/donut", + "categorical/line", "categorical/waterfall", + "timeseries/area", + "timeseries/bar", + "timeseries/line", "timeseries/waterfall", "progress", "combo-chart", @@ -21,10 +33,22 @@ const propTypes = { const StaticChart = ({ type, options }) => { switch (type) { + case "categorical/area": + return <CategoricalAreaChart {...options} />; + case "categorical/bar": + return <CategoricalBarChart {...options} />; case "categorical/donut": return <CategoricalDonutChart {...options} />; + case "categorical/line": + return <CategoricalLineChart {...options} />; case "categorical/waterfall": return <CategoricalWaterfallChart {...options} />; + case "timeseries/area": + return <TimeSeriesAreaChart {...options} />; + case "timeseries/bar": + return <TimeSeriesBarChart {...options} />; + case "timeseries/line": + return <TimeSeriesLineChart {...options} />; case "timeseries/waterfall": return <TimeSeriesWaterfallChart {...options} />; case "progress": diff --git a/frontend/src/metabase/static-viz/containers/StaticChart/StaticChart.unit.spec.js b/frontend/src/metabase/static-viz/containers/StaticChart/StaticChart.unit.spec.js index d5aa8326d544e68f52f5e73fc17b2a9d6cd7bcfb..93d5dd8c25abcc59cce899fd97613fd8f0e0b7b5 100644 --- a/frontend/src/metabase/static-viz/containers/StaticChart/StaticChart.unit.spec.js +++ b/frontend/src/metabase/static-viz/containers/StaticChart/StaticChart.unit.spec.js @@ -3,6 +3,108 @@ import { render, screen } from "@testing-library/react"; import StaticChart from "./StaticChart"; describe("StaticChart", () => { + it("should render categorical/line", () => { + render( + <StaticChart + type="categorical/line" + options={{ + data: [ + ["Gadget", 20], + ["Widget", 31], + ], + accessors: { + x: row => row[0], + y: row => row[1], + }, + settings: { + y: { + number_style: "currency", + currency: "USD", + currency_style: "symbol", + }, + }, + labels: { + left: "Count", + bottom: "Category", + }, + }} + />, + ); + + screen.getByText("Gadget"); + screen.getByText("Widget"); + screen.getAllByText("Count"); + screen.getAllByText("Category"); + }); + + it("should render categorical/area", () => { + render( + <StaticChart + type="categorical/area" + options={{ + data: [ + ["Gadget", 20], + ["Widget", 31], + ], + accessors: { + x: row => row[0], + y: row => row[1], + }, + settings: { + y: { + number_style: "currency", + currency: "USD", + currency_style: "symbol", + }, + }, + labels: { + left: "Count", + bottom: "Category", + }, + }} + />, + ); + + screen.getByText("Gadget"); + screen.getByText("Widget"); + screen.getAllByText("Count"); + screen.getAllByText("Category"); + }); + + it("should render categorical/bar", () => { + render( + <StaticChart + type="categorical/bar" + options={{ + data: [ + ["Gadget", 20], + ["Widget", 31], + ], + accessors: { + x: row => row[0], + y: row => row[1], + }, + settings: { + y: { + number_style: "currency", + currency: "USD", + currency_style: "symbol", + }, + }, + labels: { + left: "Count", + bottom: "Category", + }, + }} + />, + ); + + screen.getByText("Gadget"); + screen.getByText("Widget"); + screen.getAllByText("Count"); + screen.getAllByText("Category"); + }); + it("should render categorical/donut", () => { render( <StaticChart @@ -34,4 +136,94 @@ describe("StaticChart", () => { screen.getByText("$5,100.00"); screen.getAllByText("TOTAL"); }); + + it("should render timeseries/line", () => { + render( + <StaticChart + type="timeseries/line" + options={{ + data: [ + ["2010-11-07", 20], + ["2020-11-08", 30], + ], + accessors: { + x: row => new Date(row[0]).valueOf(), + y: row => row[1], + }, + settings: { + x: { + date_style: "dddd", + }, + }, + labels: { + left: "Count", + bottom: "Time", + }, + }} + />, + ); + + screen.getAllByText("Count"); + screen.getAllByText("Time"); + }); + + it("should render timeseries/area", () => { + render( + <StaticChart + type="timeseries/area" + options={{ + data: [ + ["2010-11-07", 20], + ["2020-11-08", 30], + ], + accessors: { + x: row => new Date(row[0]).valueOf(), + y: row => row[1], + }, + settings: { + x: { + date_style: "MMM", + }, + }, + labels: { + left: "Count", + bottom: "Time", + }, + }} + />, + ); + + screen.getAllByText("Count"); + screen.getAllByText("Time"); + }); + + it("should render timeseries/bar", () => { + render( + <StaticChart + type="timeseries/bar" + options={{ + data: [ + ["2010-11-07", 20], + ["2020-11-08", 30], + ], + accessors: { + x: row => new Date(row[0]).valueOf(), + y: row => row[1], + }, + settings: { + x: { + date_style: "dddd", + }, + }, + labels: { + left: "Count", + bottom: "Time", + }, + }} + />, + ); + + screen.getAllByText("Count"); + screen.getAllByText("Time"); + }); }); diff --git a/frontend/test/metabase-visual/internal/static-viz.cy.spec.js b/frontend/test/metabase-visual/internal/static-viz.cy.spec.js index fa63560ce7b3ec5fab84796c23426fbdfe44089a..b5f372308b5d8ac5167388bc4b77ab57a7287bbc 100644 --- a/frontend/test/metabase-visual/internal/static-viz.cy.spec.js +++ b/frontend/test/metabase-visual/internal/static-viz.cy.spec.js @@ -9,7 +9,8 @@ describe("visual tests > internal > static-viz", () => { it("basic charts", () => { cy.visit("/_internal/static-viz"); - cy.findByText("Waterfall chart with categorical data and total"); + cy.findByText("Bar chart with timeseries data"); + cy.findByText("Line chart with timeseries data"); cy.findByText("Donut chart with categorical data"); cy.percySnapshot();