diff --git a/frontend/src/metabase-types/api/card.ts b/frontend/src/metabase-types/api/card.ts index 4aa19cc6d445f7391ed6e723515166b824de0eba..e5d7010647ec687d7732d3c83f7a000e4d42d359 100644 --- a/frontend/src/metabase-types/api/card.ts +++ b/frontend/src/metabase-types/api/card.ts @@ -27,7 +27,45 @@ export interface UnsavedCard { visualization_settings: VisualizationSettings; } +export type SeriesSettings = { + title: string; + color?: string; +}; + +export type SeriesOrderSetting = { + name: string; + originalIndex: number; + enabled: boolean; +}; + export type VisualizationSettings = { + "graph.show_values"?: boolean; + "stackable.stack_type"?: "stacked" | "normalized" | null; + + // X-axis + "graph.x_axis.title_text"?: string; + "graph.x_axis.scale"?: "ordinal"; + "graph.x_axis.axis_enabled"?: "compact"; + + // Y-axis + "graph.y_axis.title_text"?: string; + "graph.y_axis.scale"?: "linear" | "pow" | "log"; + "graph.y_axis.axis_enabled"?: true; + + // Goal + "graph.goal_value"?: number; + "graph.show_goal"?: boolean; + "graph.goal_label"?: string; + + // Series + "graph.dimensions"?: string[]; + "graph.metrics"?: string[]; + + // Series settings + series_settings?: Record<string, SeriesSettings>; + + "graph.series_order"?: SeriesOrderSetting[]; + [key: string]: any; }; diff --git a/frontend/src/metabase-types/api/dataset.ts b/frontend/src/metabase-types/api/dataset.ts index 2585fa54128de943b9d5469c2c1f62bc848135bf..7db0792e60b904bdff08244a86e4cdbeb49f5143 100644 --- a/frontend/src/metabase-types/api/dataset.ts +++ b/frontend/src/metabase-types/api/dataset.ts @@ -2,6 +2,9 @@ import type { DatetimeUnit } from "metabase-types/api/query"; import { DatabaseId } from "./database"; import { DownloadPermission } from "./permissions"; +export type RowValue = string | number | null | boolean; +export type RowValues = RowValue[]; + export interface DatasetColumn { display_name: string; source: string; @@ -11,7 +14,7 @@ export interface DatasetColumn { } export interface DatasetData { - rows: any[][]; + rows: RowValues[]; cols: DatasetColumn[]; rows_truncated: number; download_perms?: DownloadPermission; diff --git a/frontend/src/metabase-types/api/mocks/dataset.ts b/frontend/src/metabase-types/api/mocks/dataset.ts index fabdfbf120a334a4c6a9369b312614385165f523..2617738ceb38c401202b2ce96655ee2ddf2d6b64 100644 --- a/frontend/src/metabase-types/api/mocks/dataset.ts +++ b/frontend/src/metabase-types/api/mocks/dataset.ts @@ -1,4 +1,17 @@ -import { Dataset, DatasetData } from "metabase-types/api/dataset"; +import { + Dataset, + DatasetColumn, + DatasetData, +} from "metabase-types/api/dataset"; + +export const createMockColumn = (data: Partial<DatasetColumn>) => { + return { + display_name: "Column", + source: "native", + name: "column", + ...data, + }; +}; type MockDatasetOpts = Partial<Omit<Dataset, "data">> & { data?: Partial<DatasetData>; @@ -7,7 +20,13 @@ type MockDatasetOpts = Partial<Omit<Dataset, "data">> & { export const createMockDataset = ({ data = {}, ...opts }: MockDatasetOpts) => ({ data: { rows: [], - cols: [{ display_name: "NAME", source: "native", name: "NAME" }], + cols: [ + createMockColumn({ + display_name: "NAME", + source: "native", + name: "NAME", + }), + ], rows_truncated: 0, ...data, }, diff --git a/frontend/src/metabase/lib/formatting/types.ts b/frontend/src/metabase/lib/formatting/types.ts index 5965efc91969c578ebd048128e510c3838eb6590..a2e77952771a4d5043e1a26e719b8b971f704224 100644 --- a/frontend/src/metabase/lib/formatting/types.ts +++ b/frontend/src/metabase/lib/formatting/types.ts @@ -7,6 +7,7 @@ export interface OptionsType { date_format?: string; date_separator?: string; date_style?: string; + decimals?: number; isExclude?: boolean; jsx?: boolean; link_text?: string; @@ -16,6 +17,8 @@ export interface OptionsType { markdown_template?: any; maximumFractionDigits?: number; noRange?: boolean; + number_separators?: string; + number_style?: string; prefix?: string; remap?: any; rich?: boolean; diff --git a/frontend/src/metabase/lib/measure-text.ts b/frontend/src/metabase/lib/measure-text.ts index 08209827edfc7d22f285a2c13c02518c3ac8edc6..0212688cb0ed2688bfa3ebc7cd2769fbe3e30674 100644 --- a/frontend/src/metabase/lib/measure-text.ts +++ b/frontend/src/metabase/lib/measure-text.ts @@ -1,12 +1,11 @@ -let canvas: HTMLCanvasElement | null = null; +import { + FontStyle, + TextMeasurer, +} from "metabase/visualizations/shared/types/measure-text"; -export type FontStyle = { - size: string; - family: string; - weight: string; -}; +let canvas: HTMLCanvasElement | null = null; -export const measureText = (text: string, style: FontStyle) => { +export const measureText: TextMeasurer = (text: string, style: FontStyle) => { canvas ??= document.createElement("canvas"); const context = canvas.getContext("2d"); @@ -15,5 +14,5 @@ export const measureText = (text: string, style: FontStyle) => { } context.font = `${style.weight} ${style.size} ${style.family}`; - return context.measureText(text); + return context.measureText(text).width; }; diff --git a/frontend/src/metabase/static-viz/components/RowChart/RowChart.tsx b/frontend/src/metabase/static-viz/components/RowChart/RowChart.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bdb83f76999af08b3013ab412b82a3b703fbdabc --- /dev/null +++ b/frontend/src/metabase/static-viz/components/RowChart/RowChart.tsx @@ -0,0 +1,88 @@ +import React, { useMemo } from "react"; +import { RowChart } from "metabase/visualizations/shared/components/RowChart"; +import { + FontStyle, + TextMeasurer, +} from "metabase/visualizations/shared/types/measure-text"; +import { measureText } from "metabase/static-viz/lib/text"; +import { getStackOffset } from "metabase/visualizations/lib/settings/stacking"; +import { + getGroupedDataset, + trimData, +} from "metabase/visualizations/shared/utils/data"; +import { getChartGoal } from "metabase/visualizations/lib/settings/goal"; +import { VisualizationSettings } from "metabase-types/api"; +import { ColorGetter } from "metabase/static-viz/lib/colors"; +import { TwoDimensionalChartData } from "metabase/visualizations/shared/types/data"; +import { getTwoDimensionalChartSeries } from "metabase/visualizations/shared/utils/series"; +import { + getLabelsFormatter, + getStaticColumnValueFormatter, + getStaticFormatters, +} from "./utils/format"; +import { getStaticChartTheme } from "./theme"; +import { getChartLabels } from "./utils/labels"; + +const WIDTH = 620; +const HEIGHT = 440; + +interface StaticRowChartProps { + data: TwoDimensionalChartData; + settings: VisualizationSettings; + getColor: ColorGetter; +} + +const staticTextMeasurer: TextMeasurer = (text: string, style: FontStyle) => + measureText( + text, + parseInt(style.size.toString(), 10), + style.weight ? parseInt(style.weight.toString(), 10) : 400, + ); + +const StaticRowChart = ({ data, settings, getColor }: StaticRowChartProps) => { + const columnValueFormatter = getStaticColumnValueFormatter(); + const labelsFormatter = getLabelsFormatter(); + const { chartColumns, series, seriesColors } = getTwoDimensionalChartSeries( + data, + settings, + columnValueFormatter, + ); + const groupedData = getGroupedDataset( + data, + chartColumns, + columnValueFormatter, + ); + const goal = getChartGoal(settings); + const theme = getStaticChartTheme(getColor); + const stackOffset = getStackOffset(settings); + const shouldShowDataLabels = + settings["graph.show_values"] && stackOffset !== "expand"; + + const tickFormatters = getStaticFormatters(chartColumns, settings); + + const { xLabel, yLabel } = getChartLabels(chartColumns, settings); + + return ( + <svg width={WIDTH} height={HEIGHT} fontFamily="Lato"> + <RowChart + width={WIDTH} + height={HEIGHT} + data={groupedData} + trimData={trimData} + series={series} + seriesColors={seriesColors} + goal={goal} + theme={theme} + stackOffset={stackOffset} + shouldShowDataLabels={shouldShowDataLabels} + tickFormatters={tickFormatters} + labelsFormatter={labelsFormatter} + measureText={staticTextMeasurer} + xLabel={xLabel} + yLabel={yLabel} + /> + </svg> + ); +}; + +export default StaticRowChart; diff --git a/frontend/src/metabase/static-viz/components/RowChart/constants.ts b/frontend/src/metabase/static-viz/components/RowChart/constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..d7ab08867ed8af2d9bac9e3a4e281a5c9a35b404 --- /dev/null +++ b/frontend/src/metabase/static-viz/components/RowChart/constants.ts @@ -0,0 +1,55 @@ +export const ROW_CHART_TYPE = "row"; + +export const ROW_CHART_DEFAULT_OPTIONS = { + settings: { + "graph.dimensions": ["CATEGORY"], + "graph.metrics": ["count"], + }, + data: { + cols: [ + { + name: "CATEGORY", + fk_field_id: 13, + field_ref: [ + "field", + 4, + { + "source-field": 13, + }, + ], + effective_type: "type/Text", + id: 4, + display_name: "Product → Category", + base_type: "type/Text", + source_alias: "PRODUCTS__via__PRODUCT_ID", + }, + { + base_type: "type/BigInteger", + semantic_type: "type/Quantity", + name: "count", + display_name: "Count", + source: "aggregation", + field_ref: ["aggregation", 0], + effective_type: "type/BigInteger", + }, + ], + rows: [ + ["Doohickey", 3976], + ["Gadget", 4939], + ["Gizmo", 4784], + ["Widget", 5061], + ], + }, +}; + +// query: { +// "source-table": ORDERS_ID, +// aggregation: [["count"]], +// breakout: [ +// ["field", PRODUCTS.CATEGORY, { "source-field": ORDERS.PRODUCT_ID }], +// ], +// }, +// visualization_settings: { +// "graph.dimensions": ["CATEGORY"], +// "graph.metrics": ["count"], +// }, diff --git a/frontend/src/metabase/static-viz/components/RowChart/index.ts b/frontend/src/metabase/static-viz/components/RowChart/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..e78d2ba01a83838688c3b1ff7e67c9e0c7fc5b63 --- /dev/null +++ b/frontend/src/metabase/static-viz/components/RowChart/index.ts @@ -0,0 +1 @@ +export { default } from "./RowChart"; diff --git a/frontend/src/metabase/static-viz/components/RowChart/theme.ts b/frontend/src/metabase/static-viz/components/RowChart/theme.ts new file mode 100644 index 0000000000000000000000000000000000000000..cdb947b2109ce23041b9788aafcdf61d39d5ecac --- /dev/null +++ b/frontend/src/metabase/static-viz/components/RowChart/theme.ts @@ -0,0 +1,43 @@ +import { ColorGetter } from "metabase/static-viz/lib/colors"; +import { RowChartTheme } from "metabase/visualizations/shared/components/RowChart/types"; + +export const getStaticChartTheme = ( + getColor: ColorGetter, + fontFamily = "Lato", +): RowChartTheme => { + return { + axis: { + color: getColor("bg-dark"), + ticks: { + size: 12, + weight: 700, + color: getColor("bg-dark"), + family: fontFamily, + }, + label: { + size: 14, + weight: 700, + color: getColor("bg-dark"), + family: fontFamily, + }, + }, + goal: { + lineStroke: getColor("text-medium"), + label: { + size: 14, + weight: 700, + color: getColor("text-medium"), + family: fontFamily, + }, + }, + dataLabels: { + weight: 700, + color: getColor("text-dark"), + size: 12, + family: fontFamily, + }, + grid: { + color: getColor("border"), + }, + }; +}; diff --git a/frontend/src/metabase/static-viz/components/RowChart/utils/format.ts b/frontend/src/metabase/static-viz/components/RowChart/utils/format.ts new file mode 100644 index 0000000000000000000000000000000000000000..0c97a879f352263829ab7b74a6fb8e777f8459a6 --- /dev/null +++ b/frontend/src/metabase/static-viz/components/RowChart/utils/format.ts @@ -0,0 +1,47 @@ +import { RowValue, VisualizationSettings } from "metabase-types/api"; +import { ChartColumns } from "metabase/visualizations/lib/graph/columns"; +import { getStackOffset } from "metabase/visualizations/lib/settings/stacking"; +import { formatNumber, formatPercent } from "metabase/static-viz/lib/numbers"; +import { ChartTicksFormatters } from "metabase/visualizations/shared/types/format"; + +export const getXValueMetricColumn = (chartColumns: ChartColumns) => { + // For multi-metrics charts we use the first metic column settings for formatting + return "breakout" in chartColumns + ? chartColumns.metric + : chartColumns.metrics[0]; +}; + +export const getStaticFormatters = ( + chartColumns: ChartColumns, + settings: VisualizationSettings, +): ChartTicksFormatters => { + // TODO: implement formatter + const yTickFormatter = (value: RowValue) => { + return String(value); + }; + + const metricColumnSettings = + settings.column_settings?.[getXValueMetricColumn(chartColumns).column.name]; + + const xTickFormatter = (value: any) => + formatNumber(value, metricColumnSettings); + + const shouldFormatXTicksAsPercent = getStackOffset(settings) === "expand"; + + return { + yTickFormatter, + xTickFormatter: shouldFormatXTicksAsPercent + ? formatPercent + : xTickFormatter, + }; +}; + +// TODO: implement formatter +export const getStaticColumnValueFormatter = () => { + return (value: any) => String(value); +}; + +// TODO: implement formatter +export const getLabelsFormatter = () => { + return (value: any) => formatNumber(value); +}; diff --git a/frontend/src/metabase/static-viz/components/RowChart/utils/labels.ts b/frontend/src/metabase/static-viz/components/RowChart/utils/labels.ts new file mode 100644 index 0000000000000000000000000000000000000000..158615dd761f085e0d6061c9d2f95404eeb85298 --- /dev/null +++ b/frontend/src/metabase/static-viz/components/RowChart/utils/labels.ts @@ -0,0 +1,33 @@ +import { VisualizationSettings } from "metabase-types/api"; +import { ChartColumns } from "metabase/visualizations/lib/graph/columns"; + +// Uses inverse axis settings to have settings compatibility between line/area/bar/combo and row charts +export const getChartLabels = ( + chartColumns: ChartColumns, + settings: VisualizationSettings, +) => { + const defaultXLabel = + "breakout" in chartColumns ? chartColumns.metric.column.display_name : ""; + const xLabelValue = settings["graph.y_axis.title_text"] ?? defaultXLabel; + + const xLabel = + (settings["graph.y_axis.labels_enabled"] ?? true) && xLabelValue.length > 0 + ? xLabelValue + : undefined; + + const defaultYLabel = + "breakout" in chartColumns + ? "" + : chartColumns.dimension.column.display_name; + const yLabelValue = settings["graph.x_axis.title_text"] ?? defaultYLabel; + const yLabel = + (settings["graph.x_axis.labels_enabled"] ?? true) && + (yLabelValue.length ?? 0) > 0 + ? yLabelValue + : undefined; + + return { + xLabel, + yLabel, + }; +}; diff --git a/frontend/src/metabase/static-viz/components/XYChart/types.ts b/frontend/src/metabase/static-viz/components/XYChart/types.ts index 11e4a9eba2404654adc67b4b4146dc146e18dd6d..e8cc0a26033b7784bfe6d810077a36c12bdd4cb2 100644 --- a/frontend/src/metabase/static-viz/components/XYChart/types.ts +++ b/frontend/src/metabase/static-viz/components/XYChart/types.ts @@ -1,17 +1,15 @@ import type { ScaleBand, ScaleLinear, ScaleTime } from "d3-scale"; import type { DateFormatOptions } from "metabase/static-viz/lib/dates"; import type { NumberFormatOptions } from "metabase/static-viz/lib/numbers"; - -export type Range = [number, number]; -export type ContinuousDomain = [number, number]; +import { ContinuousScaleType } from "metabase/visualizations/shared/types/scale"; export type XValue = string | number; export type YValue = number; export type SeriesDatum = [XValue, YValue]; export type SeriesData = SeriesDatum[]; -export type XAxisType = "timeseries" | "linear" | "ordinal" | "pow" | "log"; -export type YAxisType = "linear" | "pow" | "log"; +export type XAxisType = ContinuousScaleType | "timeseries" | "ordinal"; +export type YAxisType = ContinuousScaleType; export type YAxisPosition = "left" | "right"; @@ -62,13 +60,6 @@ export interface Dimensions { height: number; } -export interface Margin { - top: number; - right: number; - bottom: number; - left: number; -} - export type ChartStyle = { fontFamily: string; axes: { diff --git a/frontend/src/metabase/static-viz/components/XYChart/utils/bounds.ts b/frontend/src/metabase/static-viz/components/XYChart/utils/bounds.ts index bf9d37df98abe5d631b360d9f3b5e7efb1e65ba8..b81518b219f586d239a097091a7f9a0e695c4c63 100644 --- a/frontend/src/metabase/static-viz/components/XYChart/utils/bounds.ts +++ b/frontend/src/metabase/static-viz/components/XYChart/utils/bounds.ts @@ -1,4 +1,4 @@ -import { Margin } from "../types"; +import { Margin } from "metabase/visualizations/shared/types/layout"; export const calculateBounds = ( margin: Margin, 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 ccdbf80499e85050eef40ff67ad59c3be3c12e58..8212559e3f35afdde3ad6bbc100e6f1d5bc577fa 100644 --- a/frontend/src/metabase/static-viz/components/XYChart/utils/scales.ts +++ b/frontend/src/metabase/static-viz/components/XYChart/utils/scales.ts @@ -10,12 +10,13 @@ import { getX, getY, } from "metabase/static-viz/components/XYChart/utils/series"; - import type { - SeriesDatum, - XAxisType, ContinuousDomain, Range, +} from "metabase/visualizations/shared/types/scale"; +import type { + SeriesDatum, + XAxisType, Series, YAxisType, HydratedSeries, diff --git a/frontend/src/metabase/static-viz/components/XYChart/utils/ticks.ts b/frontend/src/metabase/static-viz/components/XYChart/utils/ticks.ts index fbc2d909ae15bc21f254b7e939b875d404281532..611bfe5d4bbcf01d70311ac99a59ed9e08090568 100644 --- a/frontend/src/metabase/static-viz/components/XYChart/utils/ticks.ts +++ b/frontend/src/metabase/static-viz/components/XYChart/utils/ticks.ts @@ -17,13 +17,13 @@ import { MAX_ROTATED_TICK_WIDTH } from "metabase/static-viz/components/XYChart/c import { getX } from "metabase/static-viz/components/XYChart/utils/series"; import type { - ContinuousDomain, Series, XAxisType, XValue, XScale, ChartSettings, } from "metabase/static-viz/components/XYChart/types"; +import { ContinuousDomain } from "metabase/visualizations/shared/types/scale"; const getRotatedXTickHeight = (tickWidth: number) => { return tickWidth; diff --git a/frontend/src/metabase/static-viz/containers/StaticChart/StaticChart.tsx b/frontend/src/metabase/static-viz/containers/StaticChart/StaticChart.tsx index 32cd95896af5b7bd8fe5f755d0f10d68ee5f6766..a4fbacf0c4cf139ae69dbac16f38ebb5f93dca5a 100644 --- a/frontend/src/metabase/static-viz/containers/StaticChart/StaticChart.tsx +++ b/frontend/src/metabase/static-viz/containers/StaticChart/StaticChart.tsx @@ -1,6 +1,8 @@ import React from "react"; import { createColorGetter } from "metabase/static-viz/lib/colors"; +import RowChart from "metabase/static-viz/components/RowChart"; +import { ROW_CHART_TYPE } from "metabase/static-viz/components/RowChart/constants"; import Gauge from "metabase/static-viz/components/Gauge"; import { GAUGE_CHART_TYPE } from "metabase/static-viz/components/Gauge/constants"; import CategoricalDonutChart from "metabase/static-viz/components/CategoricalDonutChart"; @@ -27,6 +29,8 @@ const StaticChart = ({ type, options }: StaticChartProps) => { return <WaterfallChart {...chartProps} />; case GAUGE_CHART_TYPE: return <Gauge {...chartProps} />; + case ROW_CHART_TYPE: + return <RowChart {...chartProps} />; case PROGRESS_BAR_TYPE: return <ProgressBar {...chartProps} />; case LINE_AREA_BAR_CHART_TYPE: diff --git a/frontend/src/metabase/static-viz/containers/StaticChart/constants.ts b/frontend/src/metabase/static-viz/containers/StaticChart/constants.ts index 635898a191efb369b4ba767893cbc3224f9dba95..ce05afa85a80a16681458d047c1e175f7dc13eef 100644 --- a/frontend/src/metabase/static-viz/containers/StaticChart/constants.ts +++ b/frontend/src/metabase/static-viz/containers/StaticChart/constants.ts @@ -1,5 +1,9 @@ import { GAUGE_CHART_TYPE } from "metabase/static-viz/components/Gauge/constants"; import { GAUGE_CHART_DEFAULT_OPTIONS } from "metabase/static-viz/components/Gauge/constants.dev"; +import { + ROW_CHART_TYPE, + ROW_CHART_DEFAULT_OPTIONS, +} from "metabase/static-viz/components/RowChart/constants"; import { CATEGORICAL_DONUT_CHART_DEFAULT_OPTIONS, CATEGORICAL_DONUT_CHART_TYPE, @@ -30,6 +34,7 @@ export const STATIC_CHART_TYPES = [ PROGRESS_BAR_TYPE, LINE_AREA_BAR_CHART_TYPE, FUNNEL_CHART_TYPE, + ROW_CHART_TYPE, ] as const; export const STATIC_CHART_DEFAULT_OPTIONS = [ @@ -40,4 +45,5 @@ export const STATIC_CHART_DEFAULT_OPTIONS = [ PROGRESS_BAR_DEFAULT_DATA_1, LINE_AREA_BAR_DEFAULT_OPTIONS_1, FUNNEL_CHART_DEFAULT_OPTIONS, + ROW_CHART_DEFAULT_OPTIONS, ] as const; diff --git a/frontend/src/metabase/visualizations/components/ScalarValue/utils.ts b/frontend/src/metabase/visualizations/components/ScalarValue/utils.ts index a79c0d3c81202e12525b5cf8162c0d51862b213c..df7de4a4b34420f4e944662e6ddee517fc9013bc 100644 --- a/frontend/src/metabase/visualizations/components/ScalarValue/utils.ts +++ b/frontend/src/metabase/visualizations/components/ScalarValue/utils.ts @@ -27,7 +27,7 @@ export const findSize = ({ size: `${size}${unit}`, family: fontFamily, weight: fontWeight, - }).width; + }); if (width > targetWidth) { while (width > targetWidth && size > min) { @@ -37,7 +37,7 @@ export const findSize = ({ size: `${size}${unit}`, family: fontFamily, weight: fontWeight, - }).width; + }); } return `${size}${unit}`; diff --git a/frontend/src/metabase/visualizations/components/ScalarValue/utils.unit.spec.ts b/frontend/src/metabase/visualizations/components/ScalarValue/utils.unit.spec.ts index 32ca4c6c7941e02b008c5c9cc12d369d2247f27d..0b56020062c416de42896bc17880423b4eb7d592 100644 --- a/frontend/src/metabase/visualizations/components/ScalarValue/utils.unit.spec.ts +++ b/frontend/src/metabase/visualizations/components/ScalarValue/utils.unit.spec.ts @@ -1,4 +1,5 @@ import * as measureText from "metabase/lib/measure-text"; +import { FontStyle } from "metabase/visualizations/shared/types/measure-text"; import { findSize } from "./utils"; jest.doMock("metabase/lib/measure-text", () => ({ @@ -6,11 +7,7 @@ jest.doMock("metabase/lib/measure-text", () => ({ })); const createMockMeasureText = (width: number) => { - return (_text: string, _style: measureText.FontStyle) => { - return { - width, - } as TextMetrics; - }; + return (_text: string, _style: FontStyle) => width; }; const defaults = { diff --git a/frontend/src/metabase/visualizations/lib/graph/columns.ts b/frontend/src/metabase/visualizations/lib/graph/columns.ts new file mode 100644 index 0000000000000000000000000000000000000000..f92d1c1808bdf89f7e01500420997eebf7b316c5 --- /dev/null +++ b/frontend/src/metabase/visualizations/lib/graph/columns.ts @@ -0,0 +1,85 @@ +import { + DatasetColumn, + DatasetData, + VisualizationSettings, +} from "metabase-types/api"; +import { TwoDimensionalChartData } from "metabase/visualizations/shared/types/data"; + +export type ColumnDescriptor = { + index: number; + column: DatasetColumn; +}; + +export const getColumnDescriptors = ( + columnNames: string[], + columns: DatasetColumn[], +): ColumnDescriptor[] => { + return columnNames.map(columnName => { + const index = columns.findIndex(column => column.name === columnName); + + return { + index, + column: columns[index], + }; + }); +}; + +export const hasValidColumnsSelected = ( + visualizationSettings: VisualizationSettings, + data: DatasetData, +) => { + const metricColumns = (visualizationSettings["graph.metrics"] ?? []) + .map(metricColumnName => + data.cols.find(column => column.name === metricColumnName), + ) + .filter(Boolean); + + const dimensionColumns = (visualizationSettings["graph.dimensions"] ?? []) + .map(dimensionColumnName => + data.cols.find(column => column.name === dimensionColumnName), + ) + .filter(Boolean); + + return metricColumns.length > 0 && dimensionColumns.length > 0; +}; + +export type BreakoutChartColumns = { + dimension: ColumnDescriptor; + breakout: ColumnDescriptor; + metric: ColumnDescriptor; +}; + +export type MultipleMetricsChartColumns = { + dimension: ColumnDescriptor; + metrics: ColumnDescriptor[]; +}; + +export type ChartColumns = BreakoutChartColumns | MultipleMetricsChartColumns; + +export const getChartColumns = ( + data: TwoDimensionalChartData, + visualizationSettings: VisualizationSettings, +): ChartColumns => { + const [dimension, breakout] = getColumnDescriptors( + visualizationSettings["graph.dimensions"] ?? [], + data.cols, + ); + + const metrics = getColumnDescriptors( + visualizationSettings["graph.metrics"] ?? [], + data.cols, + ); + + if (breakout) { + return { + dimension, + breakout, + metric: metrics[0], + }; + } + + return { + dimension, + metrics, + }; +}; diff --git a/frontend/src/metabase/visualizations/lib/settings/goal.ts b/frontend/src/metabase/visualizations/lib/settings/goal.ts new file mode 100644 index 0000000000000000000000000000000000000000..61b4264965c1909550ad2aeb639099106adf86ea --- /dev/null +++ b/frontend/src/metabase/visualizations/lib/settings/goal.ts @@ -0,0 +1,21 @@ +import { t } from "ttag"; +import { VisualizationSettings } from "metabase-types/api"; +import { ChartGoal } from "metabase/visualizations/shared/types/settings"; +import { getStackOffset } from "./stacking"; + +const getGoalValue = (value: number, isPercent: boolean) => + isPercent ? value / 100 : value; + +export const getChartGoal = ( + settings: VisualizationSettings, +): ChartGoal | null => { + if (!settings["graph.show_goal"]) { + return null; + } + const isPercent = getStackOffset(settings) === "expand"; + + return { + value: getGoalValue(settings["graph.goal_value"] ?? 0, isPercent), + label: settings["graph.goal_label"] ?? t`Goal`, + }; +}; diff --git a/frontend/src/metabase/visualizations/lib/settings/stacking.ts b/frontend/src/metabase/visualizations/lib/settings/stacking.ts new file mode 100644 index 0000000000000000000000000000000000000000..2a8481189c84ea6056b54e1af7e037f1f132a3a6 --- /dev/null +++ b/frontend/src/metabase/visualizations/lib/settings/stacking.ts @@ -0,0 +1,9 @@ +import { VisualizationSettings } from "metabase-types/api"; + +export const getStackOffset = (settings: VisualizationSettings) => { + if (settings["stackable.stack_type"] == null) { + return null; + } + + return settings["stackable.stack_type"] === "stacked" ? "none" : "expand"; +}; diff --git a/frontend/src/metabase/visualizations/shared/components/RowChart/RowChart.stories.tsx b/frontend/src/metabase/visualizations/shared/components/RowChart/RowChart.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c2d2dc07a12ff0b7b06dafba52f4eeaffe0b2a1e --- /dev/null +++ b/frontend/src/metabase/visualizations/shared/components/RowChart/RowChart.stories.tsx @@ -0,0 +1,81 @@ +import React from "react"; +import { ComponentStory } from "@storybook/react"; +import { measureText } from "metabase/lib/measure-text"; +import { getStaticChartTheme } from "metabase/static-viz/components/RowChart/theme"; +import { color } from "metabase/lib/colors"; +import { RowChart } from "./RowChart"; + +export default { + title: "Visualizations/shared/RowChart", + component: RowChart, +}; + +const Template: ComponentStory<typeof RowChart> = args => { + return ( + <div style={{ padding: 8, height: 600, backgroundColor: "white" }}> + <RowChart {...args} /> + </div> + ); +}; + +export const Default = Template.bind({}); +Default.args = { + width: 800, + height: 400, + data: [ + { + y: "Gizmo", + x1: 110, + x2: 45, + }, + { + y: "Gadget", + x1: 120, + x2: 46, + }, + { + y: "Doohickey", + x1: 30, + x2: 56, + }, + { + y: "Widget", + x1: 80, + x2: 60, + }, + ], + series: [ + { + seriesKey: "count", + seriesName: "Count", + xAccessor: (datum: any) => datum.x1, + yAccessor: (datum: any) => datum.y, + }, + { + seriesKey: "avg", + seriesName: "Average of something", + xAccessor: (datum: any) => datum.x2, + yAccessor: (datum: any) => datum.y, + }, + ], + seriesColors: { + count: color("accent3"), + avg: color("accent1"), + }, + + goal: { + label: "Very very very long goal label", + value: 100, + }, + + shouldShowDataLabels: true, + + xLabel: "X Label", + yLabel: "Y Label", + + theme: getStaticChartTheme(color), + + measureText: measureText, + + style: { fontFamily: "Lato" }, +}; diff --git a/frontend/src/metabase/visualizations/shared/components/RowChart/RowChart.tsx b/frontend/src/metabase/visualizations/shared/components/RowChart/RowChart.tsx new file mode 100644 index 0000000000000000000000000000000000000000..739a2c248d83b0687867ed5a20a70df59facba3e --- /dev/null +++ b/frontend/src/metabase/visualizations/shared/components/RowChart/RowChart.tsx @@ -0,0 +1,215 @@ +import React, { useMemo } from "react"; + +import _ from "underscore"; +import type { NumberValue } from "d3-scale"; + +import { TextMeasurer } from "metabase/visualizations/shared/types/measure-text"; +import { ChartTicksFormatters } from "metabase/visualizations/shared/types/format"; +import { HoveredData } from "metabase/visualizations/shared/types/events"; +import { RowChartView, RowChartViewProps } from "../RowChartView/RowChartView"; +import { ChartGoal } from "../../types/settings"; +import { + getMaxYValuesCount, + getChartMargin, + StackOffset, + calculateStackedBars, + calculateNonStackedBars, + getRowChartGoal, +} from "./utils/layout"; +import { getXTicks } from "./utils/ticks"; +import { RowChartTheme, Series } from "./types"; + +const MIN_BAR_HEIGHT = 24; + +const defaultFormatter = (value: any) => String(value); + +export interface RowChartProps<TDatum> { + width: number; + height: number; + + data: TDatum[]; + series: Series<TDatum>[]; + seriesColors: Record<string, string>; + + trimData?: (data: TDatum[], maxLength: number) => TDatum[]; + + goal: ChartGoal | null; + theme: RowChartTheme; + stackOffset: StackOffset; + shouldShowDataLabels?: boolean; + + yLabel?: string; + xLabel?: string; + + tickFormatters?: ChartTicksFormatters; + labelsFormatter?: (value: NumberValue) => string; + measureText: TextMeasurer; + + xScaleType?: "linear" | "pow" | "log"; + + style?: React.CSSProperties; + + hoveredData?: HoveredData | null; + onClick?: RowChartViewProps["onClick"]; + onHover?: RowChartViewProps["onHover"]; +} + +export const RowChart = <TDatum,>({ + width, + height, + + data, + trimData, + series: multipleSeries, + seriesColors, + + goal, + theme, + stackOffset, + shouldShowDataLabels, + + xLabel, + yLabel, + + tickFormatters = { + xTickFormatter: defaultFormatter, + yTickFormatter: defaultFormatter, + }, + labelsFormatter = defaultFormatter, + + xScaleType = "linear", + + measureText, + + style, + + hoveredData, + onClick, + onHover, +}: RowChartProps<TDatum>) => { + const maxYValues = useMemo( + () => + getMaxYValuesCount( + height, + MIN_BAR_HEIGHT, + stackOffset != null, + multipleSeries.length, + ), + [height, multipleSeries.length, stackOffset], + ); + + const trimmedData = trimData?.(data, maxYValues) ?? data; + + const { xTickFormatter, yTickFormatter } = tickFormatters; + + const margin = useMemo( + () => + getChartMargin( + trimmedData, + multipleSeries, + yTickFormatter, + theme.axis.ticks, + theme.axis.label, + goal != null, + measureText, + xLabel, + yLabel, + ), + [ + trimmedData, + multipleSeries, + yTickFormatter, + theme.axis.ticks, + theme.axis.label, + goal, + measureText, + xLabel, + yLabel, + ], + ); + + const innerWidth = width - margin.left - margin.right; + const innerHeight = height - margin.top - margin.bottom; + + const additionalXValues = useMemo( + () => (goal != null ? [goal.value ?? 0] : []), + [goal], + ); + + const { xScale, yScale, bars } = useMemo( + () => + stackOffset != null + ? calculateStackedBars<TDatum>({ + data: trimmedData, + multipleSeries, + additionalXValues, + stackOffset, + innerWidth, + innerHeight, + seriesColors, + xScaleType, + }) + : calculateNonStackedBars<TDatum>({ + data: trimmedData, + multipleSeries, + additionalXValues, + innerWidth, + innerHeight, + seriesColors, + xScaleType, + }), + [ + additionalXValues, + innerHeight, + innerWidth, + multipleSeries, + seriesColors, + stackOffset, + trimmedData, + xScaleType, + ], + ); + + const xTicks = useMemo( + () => + getXTicks( + theme.axis.ticks, + innerWidth, + xScale, + xTickFormatter, + measureText, + ), + [innerWidth, measureText, theme.axis.ticks, xScale, xTickFormatter], + ); + + const rowChartGoal = useMemo( + () => getRowChartGoal(goal, theme.goal, measureText, xScale), + [goal, measureText, theme.goal, xScale], + ); + + return ( + <RowChartView + style={style} + barsSeries={bars} + innerHeight={innerHeight} + innerWidth={innerWidth} + margin={margin} + theme={theme} + width={width} + height={height} + xScale={xScale} + yScale={yScale} + goal={rowChartGoal} + hoveredData={hoveredData} + yTickFormatter={yTickFormatter} + xTickFormatter={xTickFormatter} + labelsFormatter={labelsFormatter} + onClick={onClick} + onHover={onHover} + xTicks={xTicks} + shouldShowDataLabels={shouldShowDataLabels} + yLabel={yLabel} + xLabel={xLabel} + /> + ); +}; diff --git a/frontend/src/metabase/visualizations/shared/components/RowChart/constants.ts b/frontend/src/metabase/visualizations/shared/components/RowChart/constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..b22fb15b2ef35edf4eae43e0ff415f57d4c0f8c1 --- /dev/null +++ b/frontend/src/metabase/visualizations/shared/components/RowChart/constants.ts @@ -0,0 +1 @@ +export const LABEL_PADDING = 4; diff --git a/frontend/src/metabase/visualizations/shared/components/RowChart/index.ts b/frontend/src/metabase/visualizations/shared/components/RowChart/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..df7f33b8d502a33fd15c03d186611f9b6b57271c --- /dev/null +++ b/frontend/src/metabase/visualizations/shared/components/RowChart/index.ts @@ -0,0 +1 @@ +export * from "./RowChart"; diff --git a/frontend/src/metabase/visualizations/shared/components/RowChart/types.ts b/frontend/src/metabase/visualizations/shared/components/RowChart/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..a91ed43807ea3915a0bf0256b4eeaae37d10f884 --- /dev/null +++ b/frontend/src/metabase/visualizations/shared/components/RowChart/types.ts @@ -0,0 +1,21 @@ +import { AxisStyle, ChartFont, GoalStyle } from "../../types/style"; + +export type XValue = number | null; +export type yValue = string; + +export type Series<TDatum, TSeriesInfo = unknown> = { + seriesKey: string; + seriesName: string; + xAccessor: (datum: TDatum) => XValue; + yAccessor: (datum: TDatum) => yValue; + seriesInfo?: TSeriesInfo; +}; + +export type RowChartTheme = { + axis: AxisStyle; + dataLabels: ChartFont; + goal: GoalStyle; + grid: { + color: string; + }; +}; diff --git a/frontend/src/metabase/visualizations/shared/components/RowChart/utils/domain.ts b/frontend/src/metabase/visualizations/shared/components/RowChart/utils/domain.ts new file mode 100644 index 0000000000000000000000000000000000000000..ac9db32e19e185b0de9abcc0a3d31724d2035441 --- /dev/null +++ b/frontend/src/metabase/visualizations/shared/components/RowChart/utils/domain.ts @@ -0,0 +1,50 @@ +import d3 from "d3"; +import type { Series as D3Series } from "d3-shape"; +import { + ContinuousDomain, + ContinuousScaleType, +} from "metabase/visualizations/shared/types/scale"; +import { Series } from "../types"; + +export const createYDomain = <TDatum>( + data: TDatum[], + series: Series<TDatum>[], +) => { + // taking first series assuming all series have the same Y-axis values + return data.map(datum => series[0].yAccessor(datum)); +}; + +export const createXDomain = <TDatum>( + data: TDatum[], + series: Series<TDatum>[], + additionalValues: number[], + xScaleType: ContinuousScaleType, +): ContinuousDomain => { + const allXValues = series.flatMap( + series => + data + .map(datum => series.xAccessor(datum)) + .filter(value => value != null) as number[], + ); + const [min, max] = d3.extent([...allXValues, ...additionalValues]); + + if (xScaleType === "log") { + return [1, Math.max(max, 1)]; + } + + return [Math.min(min, 0), Math.max(max, 0)]; +}; + +export const createStackedXDomain = <TDatum>( + stackedSeries: D3Series<TDatum, string>[], + additionalValues: number[], + xScaleType: ContinuousScaleType, +) => { + const [min, max] = d3.extent([...stackedSeries.flat(2), ...additionalValues]); + + if (xScaleType === "log") { + return [1, Math.max(max, 1)]; + } + + return [min, max]; +}; diff --git a/frontend/src/metabase/visualizations/shared/components/RowChart/utils/layout.ts b/frontend/src/metabase/visualizations/shared/components/RowChart/utils/layout.ts new file mode 100644 index 0000000000000000000000000000000000000000..5004c242be7db592bdd7173627fcd0d9811ec03d --- /dev/null +++ b/frontend/src/metabase/visualizations/shared/components/RowChart/utils/layout.ts @@ -0,0 +1,336 @@ +import _ from "underscore"; + +import { stack, stackOffsetExpand, stackOffsetNone } from "d3-shape"; +import type { SeriesPoint } from "d3-shape"; +import { scaleBand } from "@visx/scale"; +import type { ScaleBand, ScaleContinuousNumeric, ScaleLinear } from "d3-scale"; +import { + FontStyle, + TextMeasurer, +} from "metabase/visualizations/shared/types/measure-text"; +import { Margin } from "metabase/visualizations/shared/types/layout"; +import { ContinuousScaleType } from "metabase/visualizations/shared/types/scale"; +import { + ChartFont, + GoalStyle, +} from "metabase/visualizations/shared/types/style"; +import { ChartGoal } from "metabase/visualizations/shared/types/settings"; +import { LABEL_PADDING } from "../constants"; +import { Series } from "../types"; +import { createXScale, createYScale } from "./scale"; +import { createStackedXDomain, createXDomain } from "./domain"; + +const CHART_PADDING = 10; +const TICKS_OFFSET = 10; +const GOAL_LINE_PADDING = 14; + +export const getMaxWidth = ( + formattedYTicks: string[], + ticksFont: ChartFont, + measureText: TextMeasurer, +): number => { + return Math.max( + ...formattedYTicks.map(tick => + measureText(tick, { + size: `${ticksFont.size}px`, + family: "Lato", + weight: String(ticksFont.weight ?? 400), + }), + ), + ); +}; + +export const getChartMargin = <TDatum>( + data: TDatum[], + series: Series<TDatum, unknown>[], + yTickFormatter: (value: any) => string, + ticksFont: ChartFont, + labelFont: ChartFont, + hasGoalLine: boolean, + measureText: TextMeasurer, + xLabel?: string | null, + yLabel?: string | null, +): Margin => { + const yTicksWidth = getMaxWidth( + data.flatMap(datum => + series.map(series => yTickFormatter(series.yAccessor(datum))), + ), + ticksFont, + measureText, + ); + + const margin: Margin = { + top: hasGoalLine ? GOAL_LINE_PADDING : CHART_PADDING, + left: + yTicksWidth + + TICKS_OFFSET + + CHART_PADDING + + (yLabel != null ? LABEL_PADDING + labelFont.size : 0), + bottom: + CHART_PADDING + + TICKS_OFFSET + + ticksFont.size + + (xLabel != null ? LABEL_PADDING + labelFont.size : 0), + right: CHART_PADDING, + }; + + return margin; +}; + +export const getMaxYValuesCount = ( + viewportHeight: number, + minBarWidth: number, + isStacked: boolean, + seriesCount: number, +) => { + const singleValueHeight = isStacked ? minBarWidth : minBarWidth * seriesCount; + + return Math.max(Math.floor(viewportHeight / singleValueHeight), 1); +}; + +export type StackOffset = "none" | "expand" | null; + +const StackOffsetFn = { + none: stackOffsetNone, + expand: stackOffsetExpand, +} as const; + +export type ChartBar = { + x: number; + y: number; + width: number; + height: number; + color: string; + value: number | null; +}; + +const getStackedBar = <TDatum>( + stackedDatum: SeriesPoint<TDatum>, + series: Series<TDatum>, + xScale: ScaleLinear<number, number, never>, + yScale: ScaleBand<string>, + color: string, + shouldIncludeValue: boolean, +): ChartBar | null => { + const [xStartDomain, xEndDomain] = stackedDatum; + + const x = xScale(xStartDomain); + const width = Math.abs(xScale(xEndDomain) - x); + + const height = yScale.bandwidth(); + const y = yScale(series.yAccessor(stackedDatum.data)) ?? 0; + + return { + x, + y, + height, + width, + color, + value: shouldIncludeValue ? xEndDomain : null, + }; +}; + +type CalculatedStackedChartInput<TDatum> = { + data: TDatum[]; + multipleSeries: Series<TDatum>[]; + stackOffset: StackOffset; + additionalXValues: number[]; + innerWidth: number; + innerHeight: number; + seriesColors: Record<string, string>; + xScaleType: ContinuousScaleType; +}; + +export const calculateStackedBars = <TDatum>({ + data, + multipleSeries, + stackOffset, + additionalXValues, + innerWidth, + innerHeight, + seriesColors, + xScaleType, +}: CalculatedStackedChartInput<TDatum>) => { + const seriesByKey = multipleSeries.reduce((acc, series) => { + acc[series.seriesKey] = series; + return acc; + }, {} as Record<string, Series<TDatum>>); + + const d3Stack = stack<TDatum>() + .keys(multipleSeries.map(s => s.seriesKey)) + .value((datum, seriesKey) => seriesByKey[seriesKey].xAccessor(datum) ?? 0) + .offset(StackOffsetFn[stackOffset ?? "none"]); + + const stackedSeries = d3Stack(data); + + // For log scale starting value for stack is 1 + // Stacked log charts does not make much sense but we support them, so I replicate the behavior of line/area/bar charts + if (xScaleType === "log") { + stackedSeries[0].forEach((_, index) => { + stackedSeries[0][index][0] = 1; + }); + } + + const yScale = createYScale(data, multipleSeries, innerHeight); + + const xDomain = createStackedXDomain( + stackedSeries, + additionalXValues, + xScaleType, + ); + const xScale = createXScale(xDomain, [0, innerWidth], xScaleType); + + const bars = multipleSeries.map((series, seriesIndex) => { + return data.map((_datum, datumIndex) => { + const stackedDatum = stackedSeries[seriesIndex][datumIndex]; + const shouldIncludeValue = + seriesIndex === multipleSeries.length - 1 && stackOffset === "none"; + + return getStackedBar( + stackedDatum, + series, + xScale, + yScale, + seriesColors[series.seriesKey], + shouldIncludeValue, + ); + }); + }); + + return { + xScale, + yScale, + bars, + }; +}; + +const getNonStackedBar = <TDatum>( + datum: TDatum, + series: Series<TDatum>, + xScale: ScaleLinear<number, number, never>, + yScale: ScaleBand<string>, + innerBarScale: ScaleBand<number> | null, + seriesIndex: number, + color: string, + xScaleType: ContinuousScaleType, +): ChartBar | null => { + const yValue = series.yAccessor(datum); + const xValue = series.xAccessor(datum); + const isNegative = xValue != null && xValue < 0; + + if (xValue == null) { + return null; + } + + const defaultValue = xScaleType === "log" ? 1 : 0; + + const x = xScale(isNegative ? xValue : defaultValue); + const width = Math.abs(xScale(isNegative ? defaultValue : xValue) - x); + + const height = innerBarScale?.bandwidth() ?? yScale.bandwidth(); + const innerY = innerBarScale?.(seriesIndex) ?? 0; + const y = innerY + (yScale(yValue) ?? 0); + + return { + x, + y, + height, + width, + value: xValue, + color, + }; +}; + +type CalculatedNonStackedChartInput<TDatum> = { + data: TDatum[]; + multipleSeries: Series<TDatum>[]; + additionalXValues: number[]; + innerWidth: number; + innerHeight: number; + seriesColors: Record<string, string>; + xScaleType: ContinuousScaleType; +}; + +export const calculateNonStackedBars = <TDatum>({ + data, + multipleSeries, + additionalXValues, + innerWidth, + innerHeight, + seriesColors, + xScaleType, +}: CalculatedNonStackedChartInput<TDatum>) => { + const yScale = createYScale(data, multipleSeries, innerHeight); + const xDomain = createXDomain( + data, + multipleSeries, + additionalXValues, + xScaleType, + ); + const xScale = createXScale(xDomain, [0, innerWidth], xScaleType); + + const innerBarScale = scaleBand({ + domain: multipleSeries.map((_, index) => index), + range: [0, yScale.bandwidth()], + }); + + const bars = multipleSeries.map((series, seriesIndex) => { + return data.map(datum => { + return getNonStackedBar( + datum, + series, + xScale, + yScale, + innerBarScale, + seriesIndex, + seriesColors[series.seriesKey], + xScaleType, + ); + }); + }); + + return { xScale, yScale, bars }; +}; + +type MeasurableText = { + text: string; + font: FontStyle; +}; + +export const getScaleSidePadding = ( + measureText: TextMeasurer, + borderTick: MeasurableText, + borderLabel?: MeasurableText, +) => { + const requiredTickSpace = measureText(borderTick.text, borderTick.font) / 2; + const requiredLabelSpace = borderLabel + ? measureText(borderLabel.text, borderLabel.font) + : 0; + + return Math.max(requiredLabelSpace, requiredTickSpace); +}; + +export const getRowChartGoal = ( + goal: ChartGoal | null | undefined, + style: GoalStyle, + measureText: TextMeasurer, + xScale: ScaleContinuousNumeric<number, number, never>, +) => { + if (!goal) { + return null; + } + + const labelWidth = measureText(goal.label, style.label); + const goalX = xScale(goal.value); + const xMax = xScale.range()[1]; + const availableRightSideSpace = xMax - goalX; + const position = + labelWidth > availableRightSideSpace + ? ("left" as const) + : ("right" as const); + + return { + ...goal, + position, + }; +}; diff --git a/frontend/src/metabase/visualizations/shared/components/RowChart/utils/scale.ts b/frontend/src/metabase/visualizations/shared/components/RowChart/utils/scale.ts new file mode 100644 index 0000000000000000000000000000000000000000..7b5eb37bd5669d21c83cb5a4e07237e31d500293 --- /dev/null +++ b/frontend/src/metabase/visualizations/shared/components/RowChart/utils/scale.ts @@ -0,0 +1,65 @@ +import { + ContinuousDomain, + scaleBand, + scaleLinear, + scaleLog, + scalePower, +} from "@visx/scale"; +import type { ScaleContinuousNumeric } from "d3-scale"; +import { + ContinuousScaleType, + Range, +} from "metabase/visualizations/shared/types/scale"; +import { Series } from "../types"; +import { createYDomain } from "./domain"; + +export const createYScale = <TDatum>( + data: TDatum[], + series: Series<TDatum>[], + chartHeight: number, +) => { + return scaleBand({ + domain: createYDomain(data, series), + range: [0, chartHeight], + padding: 0.2, + }); +}; + +export const createXScale = ( + domain: ContinuousDomain, + range: Range, + type: ContinuousScaleType = "linear", +) => { + switch (type) { + case "pow": + return scalePower({ + range, + domain, + exponent: 2, + }); + case "log": + return scaleLog({ + range, + domain, + base: 10, + }); + default: + return scaleLinear({ + range, + domain, + nice: true, + }); + } +}; + +export const addScalePadding = ( + scale: ScaleContinuousNumeric<number, number, unknown>, + paddingStart: number = 0, + paddingEnd: number = 0, +) => { + const [start, end] = scale.range(); + const adjustedDomainStart = scale.invert(start - paddingStart); + const adjustedDomainEnd = scale.invert(end + paddingEnd); + + return scale.domain([adjustedDomainStart, adjustedDomainEnd]); +}; diff --git a/frontend/src/metabase/visualizations/shared/components/RowChart/utils/ticks.ts b/frontend/src/metabase/visualizations/shared/components/RowChart/utils/ticks.ts new file mode 100644 index 0000000000000000000000000000000000000000..c11c1eba47b73d7fb24f1ecf23629349d1a02c35 --- /dev/null +++ b/frontend/src/metabase/visualizations/shared/components/RowChart/utils/ticks.ts @@ -0,0 +1,72 @@ +import type { ScaleLinear } from "d3-scale"; +import { ValueFormatter } from "metabase/visualizations/shared/types/format"; +import { TextMeasurer } from "metabase/visualizations/shared/types/measure-text"; +import { ChartFont } from "metabase/visualizations/shared/types/style"; + +const TICK_SPACING = 4; + +const getMinTickInterval = (innerWidth: number) => innerWidth / 4; + +const omitOverlappingTicks = ( + ticks: number[], + ticksFont: ChartFont, + xScale: ScaleLinear<number, number, never>, + xTickFormatter: ValueFormatter, + measureText: TextMeasurer, +) => { + if (ticks.length <= 1) { + return ticks; + } + + const nonOverlappingTicks = [ticks[0]]; + let nextAvailableX = + measureText(xTickFormatter(ticks[0]), ticksFont) / 2 + TICK_SPACING; + + for (let i = 1; i < ticks.length; i++) { + const currentTick = ticks[i]; + const currentTickWidth = measureText( + xTickFormatter(currentTick), + ticksFont, + ); + const currentTickX = xScale(currentTick); + const currentTickStart = currentTickX - currentTickWidth / 2; + + if (currentTickStart < nextAvailableX) { + continue; + } + + nonOverlappingTicks.push(currentTick); + nextAvailableX = currentTickX + currentTickWidth / 2 + TICK_SPACING; + } + + return nonOverlappingTicks; +}; + +export const getXTicks = ( + ticksFont: ChartFont, + innerWidth: number, + xScale: ScaleLinear<number, number, never>, + xTickFormatter: ValueFormatter, + measureText: TextMeasurer, +) => { + // Assume border ticks on a continuous scale are the widest + const borderTicksWidths = xScale + .domain() + .map(tick => measureText(xTickFormatter(tick), ticksFont) + TICK_SPACING); + + const ticksInterval = Math.max( + ...borderTicksWidths, + getMinTickInterval(innerWidth), + ); + + const ticksCount = Math.floor(innerWidth / ticksInterval); + const ticks = xScale.ticks(ticksCount); + + return omitOverlappingTicks( + ticks, + ticksFont, + xScale, + xTickFormatter, + measureText, + ); +}; diff --git a/frontend/src/metabase/visualizations/shared/components/RowChartView/RowChartView.tsx b/frontend/src/metabase/visualizations/shared/components/RowChartView/RowChartView.tsx new file mode 100644 index 0000000000000000000000000000000000000000..90ded9a71bfdb50defeb1be2c04764433da954e9 --- /dev/null +++ b/frontend/src/metabase/visualizations/shared/components/RowChartView/RowChartView.tsx @@ -0,0 +1,223 @@ +import React from "react"; +import { Group } from "@visx/group"; +import { AxisBottom, AxisLeft } from "@visx/axis"; +import { Bar } from "@visx/shape"; +import type { NumberValue, ScaleBand, ScaleLinear } from "d3-scale"; +import { Text } from "@visx/text"; +import { GridColumns } from "@visx/grid"; +import { HoveredData } from "metabase/visualizations/shared/types/events"; +import { Margin } from "metabase/visualizations/shared/types/layout"; +import { ChartBar } from "../RowChart/utils/layout"; +import { VerticalGoalLine } from "../VerticalGoalLine/VerticalGoalLine"; +import { RowChartTheme } from "../RowChart/types"; + +export interface RowChartViewProps { + width: number; + height: number; + yScale: ScaleBand<string>; + xScale: ScaleLinear<number, number, never>; + barsSeries: (ChartBar | null)[][]; + labelsFormatter: (value: NumberValue) => string; + yTickFormatter: (value: string | number) => string; + xTickFormatter: (value: NumberValue) => string; + goal: { + label: string; + value: number; + position: "left" | "right"; + } | null; + theme: RowChartTheme; + margin: Margin; + innerWidth: number; + innerHeight: number; + xTicks: number[]; + shouldShowDataLabels?: boolean; + xLabel?: string | null; + yLabel?: string | null; + style?: React.CSSProperties; + hoveredData?: HoveredData | null; + onHover?: ( + event: React.MouseEvent, + seriesIndex: number | null, + datumIndex: number | null, + ) => void; + onClick?: ( + event: React.MouseEvent, + seriesIndex: number, + datumIndex: number, + ) => void; +} + +export const RowChartView = ({ + width, + height, + innerHeight, + xScale, + yScale, + barsSeries, + goal, + theme, + margin, + labelsFormatter, + yTickFormatter, + xTickFormatter, + xTicks, + shouldShowDataLabels, + yLabel, + xLabel, + style, + hoveredData, + onHover, + onClick, +}: RowChartViewProps) => { + const handleBarMouseEnter = ( + event: React.MouseEvent, + seriesIndex: number, + datumIndex: number, + ) => { + onHover?.(event, seriesIndex, datumIndex); + }; + + const handleBarMouseLeave = (event: React.MouseEvent) => { + onHover?.(event, null, null); + }; + + const handleClick = ( + event: React.MouseEvent, + seriesIndex: number, + datumIndex: number, + ) => { + onClick?.(event, seriesIndex, datumIndex); + }; + + const goalLineX = xScale(goal?.value ?? 0); + + return ( + <svg width={width} height={height} style={style}> + <Group top={margin.top} left={margin.left}> + <GridColumns + scale={xScale} + height={innerHeight} + stroke={theme.grid.color} + tickValues={xTicks} + /> + + {barsSeries.map((series, seriesIndex) => { + return series.map((bar, datumIndex) => { + if (bar == null) { + return null; + } + + const { x, y, width, height, value, color } = bar; + + const hasSeriesHover = hoveredData != null; + const isSeriesHovered = hoveredData?.seriesIndex === seriesIndex; + const isDatumHovered = hoveredData?.datumIndex === datumIndex; + + const shouldHighlightBar = + barsSeries.length === 1 && isDatumHovered; + const shouldHighlightSeries = + barsSeries.length > 1 && isSeriesHovered; + + const opacity = + !hasSeriesHover || shouldHighlightSeries || shouldHighlightBar + ? 1 + : 0.4; + + const isLabelVisible = shouldShowDataLabels && value != null; + + return ( + <> + <Bar + style={{ transition: "opacity 300ms", cursor: "pointer" }} + key={`${seriesIndex}:${datumIndex}`} + x={x} + y={y} + width={width} + height={height} + fill={color} + opacity={opacity} + onClick={event => handleClick(event, seriesIndex, datumIndex)} + onMouseEnter={event => + handleBarMouseEnter(event, seriesIndex, datumIndex) + } + onMouseLeave={handleBarMouseLeave} + /> + {isLabelVisible && ( + <Text + fontSize={theme.dataLabels.size} + fill={theme.dataLabels.color} + fontWeight={theme.dataLabels.weight} + dx="0.33em" + x={x + width} + y={y + height / 2} + verticalAnchor="middle" + > + {labelsFormatter(value)} + </Text> + )} + </> + ); + }); + })} + + {goal && ( + <VerticalGoalLine + x={goalLineX} + height={innerHeight} + label={goal.label} + style={theme.goal} + position={goal.position} + /> + )} + + <AxisLeft + label={yLabel ?? ""} + labelProps={{ + fill: theme.axis.label.color, + fontSize: theme.axis.label.size, + fontWeight: theme.axis.label.weight, + textAnchor: "middle", + verticalAnchor: "start", + }} + labelOffset={margin.left - theme.axis.label.size} + tickFormat={yTickFormatter} + hideTicks + numTicks={Infinity} + scale={yScale} + stroke={theme.axis.color} + tickStroke={theme.axis.color} + tickLabelProps={() => ({ + fill: theme.axis.color, + fontSize: theme.axis.ticks.size, + fontWeight: theme.axis.ticks.weight, + textAnchor: "end", + dy: "0.33em", + })} + /> + <AxisBottom + label={xLabel ?? ""} + labelProps={{ + fill: theme.axis.label.color, + fontSize: theme.axis.label.size, + fontWeight: theme.axis.label.weight, + verticalAnchor: "end", + textAnchor: "middle", + }} + hideTicks + tickValues={xTicks} + tickFormat={xTickFormatter} + top={innerHeight} + scale={xScale} + stroke={theme.axis.color} + tickStroke={theme.axis.color} + tickLabelProps={() => ({ + fill: theme.axis.ticks.color, + fontSize: theme.axis.ticks.size, + fontWeight: theme.axis.ticks.weight, + textAnchor: "middle", + })} + /> + </Group> + </svg> + ); +}; diff --git a/frontend/src/metabase/visualizations/shared/components/RowChartView/index.ts b/frontend/src/metabase/visualizations/shared/components/RowChartView/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..e645c84462055949e04b1618ea4813e86f1ea73e --- /dev/null +++ b/frontend/src/metabase/visualizations/shared/components/RowChartView/index.ts @@ -0,0 +1 @@ +export * from "./RowChartView"; diff --git a/frontend/src/metabase/visualizations/shared/components/VerticalGoalLine/VerticalGoalLine.tsx b/frontend/src/metabase/visualizations/shared/components/VerticalGoalLine/VerticalGoalLine.tsx new file mode 100644 index 0000000000000000000000000000000000000000..611a8a79fe79c53ff04cb173f7c1b5da8a17a291 --- /dev/null +++ b/frontend/src/metabase/visualizations/shared/components/VerticalGoalLine/VerticalGoalLine.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { Line } from "@visx/shape"; +import { Text } from "@visx/text"; +import { GoalStyle } from "../../types/style"; + +interface VerticalGoalLineProps { + x: number; + height: number; + label: string; + position: "left" | "right"; + style: GoalStyle; +} + +export const VerticalGoalLine = ({ + x, + height, + label, + style, + position = "right", +}: VerticalGoalLineProps) => { + const textAnchor = position === "right" ? "start" : "end"; + + return ( + <> + <Text + y={0} + textAnchor={textAnchor} + verticalAnchor="end" + dy="-0.2em" + x={x} + fill={style.label.color} + fontSize={style.label.size} + fontWeight={style.label.weight} + > + {label} + </Text> + <Line + strokeDasharray={4} + stroke={style.lineStroke} + strokeWidth={2} + y1={0} + y2={height} + x1={x} + x2={x} + /> + </> + ); +}; diff --git a/frontend/src/metabase/visualizations/shared/components/VerticalGoalLine/index.ts b/frontend/src/metabase/visualizations/shared/components/VerticalGoalLine/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..e8e6872d86c584cf718b332c992c6440df9f800c --- /dev/null +++ b/frontend/src/metabase/visualizations/shared/components/VerticalGoalLine/index.ts @@ -0,0 +1 @@ +export * from "./VerticalGoalLine"; diff --git a/frontend/src/metabase/visualizations/shared/types/data.ts b/frontend/src/metabase/visualizations/shared/types/data.ts new file mode 100644 index 0000000000000000000000000000000000000000..9d4e5d15614edefe6b9298ad8c264c0df288336d --- /dev/null +++ b/frontend/src/metabase/visualizations/shared/types/data.ts @@ -0,0 +1,22 @@ +import { DatasetColumn, DatasetData, RowValue } from "metabase-types/api"; + +export type TwoDimensionalChartData = Pick<DatasetData, "rows" | "cols">; + +export type SeriesInfo = { + metricColumn: DatasetColumn; + dimensionColumn: DatasetColumn; + breakoutValue?: RowValue; +}; + +export type MetricValue = number | null; +export type MetricName = string; +export type BreakoutName = string; +export type MetricDatum = { [key: MetricName]: MetricValue }; + +export type GroupedDatum = { + dimensionValue: RowValue; + metrics: MetricDatum; + breakout?: { [key: BreakoutName]: MetricDatum }; +}; + +export type GroupedDataset = GroupedDatum[]; diff --git a/frontend/src/metabase/visualizations/shared/types/events.ts b/frontend/src/metabase/visualizations/shared/types/events.ts new file mode 100644 index 0000000000000000000000000000000000000000..cbee451e1d6e179b876435bd68d0288bc790d3e6 --- /dev/null +++ b/frontend/src/metabase/visualizations/shared/types/events.ts @@ -0,0 +1,4 @@ +export type HoveredData = { + seriesIndex: number; + datumIndex: number; +}; diff --git a/frontend/src/metabase/visualizations/shared/types/format.ts b/frontend/src/metabase/visualizations/shared/types/format.ts new file mode 100644 index 0000000000000000000000000000000000000000..a041ca11c546ecb2e133d4618deeed46a52c4173 --- /dev/null +++ b/frontend/src/metabase/visualizations/shared/types/format.ts @@ -0,0 +1,10 @@ +import { DatasetColumn } from "metabase-types/api"; + +export type ValueFormatter = (value: any) => string; + +export type ColumnFormatter = (value: any, column: DatasetColumn) => string; + +export type ChartTicksFormatters = { + xTickFormatter: ValueFormatter; + yTickFormatter: ValueFormatter; +}; diff --git a/frontend/src/metabase/visualizations/shared/types/layout.ts b/frontend/src/metabase/visualizations/shared/types/layout.ts new file mode 100644 index 0000000000000000000000000000000000000000..b501e5aaae8efe569c134180299be464f1a65c67 --- /dev/null +++ b/frontend/src/metabase/visualizations/shared/types/layout.ts @@ -0,0 +1,6 @@ +export type Margin = { + top: number; + bottom: number; + right: number; + left: number; +}; diff --git a/frontend/src/metabase/visualizations/shared/types/measure-text.ts b/frontend/src/metabase/visualizations/shared/types/measure-text.ts new file mode 100644 index 0000000000000000000000000000000000000000..7921d1a16d1648cad7c0c336eced1a4cc3746fd0 --- /dev/null +++ b/frontend/src/metabase/visualizations/shared/types/measure-text.ts @@ -0,0 +1,7 @@ +export type FontStyle = { + size: string | number; + family: string; + weight: string | number; +}; + +export type TextMeasurer = (text: string, style: FontStyle) => number; diff --git a/frontend/src/metabase/visualizations/shared/types/scale.ts b/frontend/src/metabase/visualizations/shared/types/scale.ts new file mode 100644 index 0000000000000000000000000000000000000000..3081ccf894a982da02c3dd51ce81048f5a24dbdf --- /dev/null +++ b/frontend/src/metabase/visualizations/shared/types/scale.ts @@ -0,0 +1,3 @@ +export type ContinuousScaleType = "linear" | "pow" | "log"; +export type ContinuousDomain = [number, number]; +export type Range = [number, number]; diff --git a/frontend/src/metabase/visualizations/shared/types/settings.ts b/frontend/src/metabase/visualizations/shared/types/settings.ts new file mode 100644 index 0000000000000000000000000000000000000000..5ebbf4a4ef0f68b6c0d1f45c4b891802f30ecc95 --- /dev/null +++ b/frontend/src/metabase/visualizations/shared/types/settings.ts @@ -0,0 +1,4 @@ +export type ChartGoal = { + label: string; + value: number; +}; diff --git a/frontend/src/metabase/visualizations/shared/types/style.ts b/frontend/src/metabase/visualizations/shared/types/style.ts new file mode 100644 index 0000000000000000000000000000000000000000..70e88e7fb590a9d001c59ae21be45fd5e8401737 --- /dev/null +++ b/frontend/src/metabase/visualizations/shared/types/style.ts @@ -0,0 +1,17 @@ +export type ChartFont = { + size: number; + family: string; + weight: number; + color: string; +}; + +export type GoalStyle = { + lineStroke: string; + label: ChartFont; +}; + +export type AxisStyle = { + color: string; + ticks: ChartFont; + label: ChartFont; +}; diff --git a/frontend/src/metabase/visualizations/shared/utils/colors.ts b/frontend/src/metabase/visualizations/shared/utils/colors.ts new file mode 100644 index 0000000000000000000000000000000000000000..ca269dd80a2936210d822f20d6d62427fd7d2dfa --- /dev/null +++ b/frontend/src/metabase/visualizations/shared/utils/colors.ts @@ -0,0 +1,23 @@ +import { VisualizationSettings } from "metabase-types/api"; +import { getColorsForValues } from "metabase/lib/colors/charts"; +import { Series } from "../components/RowChart/types"; + +export const getSeriesColors = <TDatum, TSeriesInfo>( + settings: VisualizationSettings, + series: Series<TDatum, TSeriesInfo>[], +): Record<string, string> => { + const settingsColorMapping = Object.entries( + settings.series_settings ?? {}, + ).reduce((mapping, [seriesName, seriesSettings]) => { + if (typeof seriesSettings.color === "string") { + mapping[seriesName] = seriesSettings.color; + } + + return mapping; + }, {} as Record<string, string>); + + return getColorsForValues( + series.map(series => series.seriesKey), + settingsColorMapping, + ); +}; diff --git a/frontend/src/metabase/visualizations/shared/utils/data.ts b/frontend/src/metabase/visualizations/shared/utils/data.ts new file mode 100644 index 0000000000000000000000000000000000000000..4770e13b0c6da98e9da3cd6173ec33b0e06d14b1 --- /dev/null +++ b/frontend/src/metabase/visualizations/shared/utils/data.ts @@ -0,0 +1,248 @@ +import { t } from "ttag"; +import { RowValue, RowValues, SeriesOrderSetting } from "metabase-types/api"; + +import { + ChartColumns, + ColumnDescriptor, + getColumnDescriptors, +} from "metabase/visualizations/lib/graph/columns"; +import { ColumnFormatter } from "metabase/visualizations/shared/types/format"; +import { + GroupedDataset, + GroupedDatum, + MetricDatum, + MetricValue, + SeriesInfo, + TwoDimensionalChartData, +} from "metabase/visualizations/shared/types/data"; +import { Series } from "metabase/visualizations/shared/components/RowChart/types"; +import { isMetric } from "metabase-lib/lib/types/utils/isa"; + +const getMetricValue = (value: RowValue): MetricValue => { + if (typeof value === "number") { + return value; + } + + return null; +}; + +const sumMetrics = (left: MetricDatum, right: MetricDatum): MetricDatum => { + const keys = new Set([...Object.keys(left), ...Object.keys(right)]); + return Array.from(keys).reduce<MetricDatum>((datum, metricKey) => { + const leftValue = left[metricKey]; + const rightValue = right[metricKey]; + + if (typeof leftValue === "number" || typeof rightValue === "number") { + datum[metricKey] = (leftValue ?? 0) + (rightValue ?? 0); + } else { + datum[metricKey] = null; + } + + return datum; + }, {}); +}; + +const groupDataByDimensions = ( + rows: RowValues[], + chartColumns: ChartColumns, + allMetrics: ColumnDescriptor[], + columnFormatter: ColumnFormatter, +): GroupedDataset => { + const { dimension } = chartColumns; + + const groupedData = new Map<RowValue, GroupedDatum>(); + + for (const row of rows) { + const dimensionValue = row[dimension.index]; + + const datum = groupedData.get(dimensionValue) ?? { + dimensionValue, + metrics: {}, + }; + + const rowMetrics = allMetrics.reduce<MetricDatum>((datum, metric) => { + datum[metric.column.name] = getMetricValue(row[metric.index]); + return datum; + }, {}); + + datum.metrics = sumMetrics(rowMetrics, datum.metrics); + + if ("breakout" in chartColumns) { + const breakoutName = columnFormatter( + row[chartColumns.breakout.index], + chartColumns.breakout.column, + ); + + datum.breakout = { + ...datum.breakout, + [breakoutName]: sumMetrics( + rowMetrics, + datum.breakout?.[breakoutName] ?? {}, + ), + }; + } + + groupedData.set(dimensionValue, datum); + } + + return Array.from(groupedData.values()); +}; + +export const getGroupedDataset = ( + data: TwoDimensionalChartData, + chartColumns: ChartColumns, + columnFormatter: ColumnFormatter, +): GroupedDataset => { + // We are grouping all metrics because they are used in chart tooltips + const allMetricColumns = data.cols + .filter(isMetric) + .map(column => column.name); + + const allMetricDescriptors = getColumnDescriptors( + allMetricColumns, + data.cols, + ); + + return groupDataByDimensions( + data.rows, + chartColumns, + allMetricDescriptors, + columnFormatter, + ); +}; + +export const trimData = ( + dataset: GroupedDataset, + valuesLimit: number, +): GroupedDataset => { + if (dataset.length <= valuesLimit) { + return dataset; + } + + const groupStartingFromIndex = valuesLimit - 1; + const result = dataset.slice(); + const dataToGroup = result.splice(groupStartingFromIndex); + + const groupedDatumDimensionValue = + dataToGroup.length === dataset.length + ? t`All values (${dataToGroup.length})` + : t`Other (${dataToGroup.length})`; + + const groupedValuesDatum = dataToGroup.reduce( + (groupedValue, currentValue) => { + groupedValue.metrics = sumMetrics( + groupedValue.metrics, + currentValue.metrics, + ); + + Object.keys(currentValue.breakout ?? {}).map(breakoutName => { + groupedValue.breakout ??= {}; + + groupedValue.breakout[breakoutName] = sumMetrics( + groupedValue.breakout[breakoutName] ?? {}, + currentValue.breakout?.[breakoutName] ?? {}, + ); + }); + + return groupedValue; + }, + { + dimensionValue: groupedDatumDimensionValue, + metrics: {}, + breakout: {}, + }, + ); + + return [...result, groupedValuesDatum]; +}; + +const getBreakoutDistinctValues = ( + data: TwoDimensionalChartData, + breakout: ColumnDescriptor, + columnFormatter: ColumnFormatter, +) => { + return Array.from( + new Set( + data.rows.map(row => + columnFormatter(row[breakout.index], breakout.column), + ), + ), + ); +}; + +const getBreakoutSeries = ( + breakoutValues: RowValue[], + metric: ColumnDescriptor, + dimension: ColumnDescriptor, +): Series<GroupedDatum, SeriesInfo>[] => { + return breakoutValues.map((breakoutValue, seriesIndex) => { + const breakoutName = String(breakoutValue); + return { + seriesKey: breakoutName, + seriesName: breakoutName, + yAccessor: (datum: GroupedDatum) => String(datum.dimensionValue), + xAccessor: (datum: GroupedDatum) => + datum.breakout?.[breakoutName]?.[metric.column.name] ?? null, + seriesInfo: { + metricColumn: metric.column, + dimensionColumn: dimension.column, + breakoutValue, + }, + }; + }); +}; + +const getMultipleMetricSeries = ( + dimension: ColumnDescriptor, + metrics: ColumnDescriptor[], +): Series<GroupedDatum, SeriesInfo>[] => { + return metrics.map(metric => { + return { + seriesKey: metric.column.name, + seriesName: metric.column.display_name ?? metric.column.name, + yAccessor: (datum: GroupedDatum) => String(datum.dimensionValue), + xAccessor: (datum: GroupedDatum) => datum.metrics[metric.column.name], + seriesInfo: { + dimensionColumn: dimension.column, + metricColumn: metric.column, + }, + }; + }); +}; + +export const getSeries = ( + data: TwoDimensionalChartData, + chartColumns: ChartColumns, + columnFormatter: ColumnFormatter, +): Series<GroupedDatum, SeriesInfo>[] => { + let series: Series<GroupedDatum, SeriesInfo>[]; + + if ("breakout" in chartColumns) { + const breakoutValues = getBreakoutDistinctValues( + data, + chartColumns.breakout, + columnFormatter, + ); + + return getBreakoutSeries( + breakoutValues, + chartColumns.metric, + chartColumns.dimension, + ); + } + + return getMultipleMetricSeries(chartColumns.dimension, chartColumns.metrics); +}; + +export const getOrderedSeries = ( + series: Series<GroupedDatum, SeriesInfo>[], + seriesOrder?: SeriesOrderSetting[], +) => { + if (seriesOrder == null) { + return series; + } + + return seriesOrder + .filter(orderSetting => orderSetting.enabled) + .map(orderSetting => series[orderSetting.originalIndex]); +}; diff --git a/frontend/src/metabase/visualizations/shared/utils/data.unit.spec.ts b/frontend/src/metabase/visualizations/shared/utils/data.unit.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..602c6bc8d2eb14a6fb96aaa06827300ca2bfeb79 --- /dev/null +++ b/frontend/src/metabase/visualizations/shared/utils/data.unit.spec.ts @@ -0,0 +1,146 @@ +import { DatasetColumn, DatasetData } from "metabase-types/api"; +import { createMockColumn } from "metabase-types/api/mocks"; +import { + BreakoutChartColumns, + MultipleMetricsChartColumns, +} from "metabase/visualizations/lib/graph/columns"; +import { ColumnFormatter } from "metabase/visualizations/shared/types/format"; +import { getGroupedDataset } from "./data"; + +jest.mock("metabase-lib/lib/types/utils/isa", () => ({ + isMetric: jest.fn((column: DatasetColumn) => + ["avg", "count"].includes(column.name), + ), +})); + +const columnFormatter: ColumnFormatter = (value: any) => String(value); + +const dimensionColumn = createMockColumn({ name: "year" }); +const breakoutColumn = createMockColumn({ name: "category" }); +const countMetricColumn = createMockColumn({ + name: "count", +}); +const avgMetricColumn = createMockColumn({ + name: "avg", +}); + +const dataset: DatasetData = { + cols: [dimensionColumn, breakoutColumn, countMetricColumn, avgMetricColumn], + rows: [ + [2020, "Doohickey", 400, 90], + [2020, "Gadget", 450, 100], + [2021, "Doohickey", 500, 110], + [2021, "Gadget", 550, 120], + ], + rows_truncated: 0, +}; + +const breakoutChartColumns: BreakoutChartColumns = { + dimension: { + column: dimensionColumn, + index: 0, + }, + breakout: { + column: breakoutColumn, + index: 1, + }, + metric: { + column: countMetricColumn, + index: 2, + }, +}; + +const multipleMetricsChartColumns: MultipleMetricsChartColumns = { + dimension: { + column: dimensionColumn, + index: 0, + }, + metrics: [ + { + column: countMetricColumn, + index: 2, + }, + { + column: avgMetricColumn, + index: 3, + }, + ], +}; + +describe("data utils", () => { + describe("getGroupedDataset", () => { + describe("chart with multiple metrics", () => { + it("should group dataset by dimension values", () => { + const groupedData = getGroupedDataset( + dataset, + multipleMetricsChartColumns, + columnFormatter, + ); + + expect(groupedData).toStrictEqual([ + { + dimensionValue: 2020, + metrics: { + count: 850, + avg: 190, + }, + }, + { + dimensionValue: 2021, + metrics: { + count: 1050, + avg: 230, + }, + }, + ]); + }); + }); + }); + + describe("chart with a breakout", () => { + it("should group dataset by dimension values and breakout", () => { + const groupedData = getGroupedDataset( + dataset, + breakoutChartColumns, + columnFormatter, + ); + + expect(groupedData).toStrictEqual([ + { + dimensionValue: 2020, + metrics: { + count: 850, + avg: 190, + }, + breakout: { + Doohickey: { + count: 400, + avg: 90, + }, + Gadget: { + count: 450, + avg: 100, + }, + }, + }, + { + dimensionValue: 2021, + metrics: { + count: 1050, + avg: 230, + }, + breakout: { + Doohickey: { + count: 500, + avg: 110, + }, + Gadget: { + count: 550, + avg: 120, + }, + }, + }, + ]); + }); + }); +}); diff --git a/frontend/src/metabase/visualizations/shared/utils/series.ts b/frontend/src/metabase/visualizations/shared/utils/series.ts new file mode 100644 index 0000000000000000000000000000000000000000..2317aa6fe5819abda6d7050d8a4d5f52c7f63635 --- /dev/null +++ b/frontend/src/metabase/visualizations/shared/utils/series.ts @@ -0,0 +1,25 @@ +import { VisualizationSettings } from "metabase-types/api"; +import { getChartColumns } from "metabase/visualizations/lib/graph/columns"; +import { ColumnFormatter } from "metabase/visualizations/shared/types/format"; +import { TwoDimensionalChartData } from "../types/data"; +import { getOrderedSeries, getSeries } from "./data"; +import { getSeriesColors } from "./colors"; + +export const getTwoDimensionalChartSeries = ( + data: TwoDimensionalChartData, + settings: VisualizationSettings, + columnFormatter: ColumnFormatter, +) => { + const chartColumns = getChartColumns(data, settings); + const unorderedSeries = getSeries(data, chartColumns, columnFormatter); + const seriesOrder = settings["graph.series_order"]; + const series = getOrderedSeries(unorderedSeries, seriesOrder); + + const seriesColors = getSeriesColors(settings, series); + + return { + chartColumns, + series, + seriesColors, + }; +}; diff --git a/frontend/test/metabase-visual/static-visualizations/row.cy.spec.js b/frontend/test/metabase-visual/static-visualizations/row.cy.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..a58fe14056d6351b62e8cc1b9c7f1d555a340f2d --- /dev/null +++ b/frontend/test/metabase-visual/static-visualizations/row.cy.spec.js @@ -0,0 +1,57 @@ +import { + restore, + setupSMTP, + openEmailPage, + sendSubscriptionsEmail, + visitDashboard, +} from "__support__/e2e/helpers"; + +import { USERS, SAMPLE_DB_ID } from "__support__/e2e/cypress_data"; +import { SAMPLE_DATABASE } from "__support__/e2e/cypress_sample_database"; + +const { ORDERS_ID, ORDERS, PRODUCTS } = SAMPLE_DATABASE; + +const { admin } = USERS; + +describe("static visualizations", { tags: "@external" }, () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + setupSMTP(); + }); + + it(`row chart`, () => { + const dashboardName = `Row charts dashboard`; + cy.createDashboardWithQuestions({ + dashboardName, + questions: [createSingleSeriesRowChart()], + }).then(({ dashboard }) => { + visitDashboard(dashboard.id); + + sendSubscriptionsEmail(`${admin.first_name} ${admin.last_name}`); + + openEmailPage(dashboardName).then(() => { + cy.createPercySnapshot(); + }); + }); + }); +}); + +function createSingleSeriesRowChart() { + return { + name: `Single series row chart`, + query: { + "source-table": ORDERS_ID, + aggregation: [["count"]], + breakout: [ + ["field", PRODUCTS.CATEGORY, { "source-field": ORDERS.PRODUCT_ID }], + ], + }, + visualization_settings: { + "graph.dimensions": ["CATEGORY"], + "graph.metrics": ["count"], + }, + display: "row", + database: SAMPLE_DB_ID, + }; +} diff --git a/jest.tz.unit.conf.json b/jest.tz.unit.conf.json index 4015d30ad34feed3a3c2e46df5ecd2d6c86d62d5..08e5117b27a8f0992ba21f1b4194b0c76d8db27c 100644 --- a/jest.tz.unit.conf.json +++ b/jest.tz.unit.conf.json @@ -2,7 +2,8 @@ "moduleNameMapper": { "\\.(css|less)$": "<rootDir>/frontend/test/__mocks__/styleMock.js", "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/frontend/test/__mocks__/fileMock.js", - "^promise-loader\\?global\\!metabase-lib\\/lib\\/metadata\\/utils\\/ga-metadata$": "<rootDir>/frontend/src/metabase-lib/lib/metadata/utils/ga-metadata.js" + "^promise-loader\\?global\\!metabase-lib\\/lib\\/metadata\\/utils\\/ga-metadata$": "<rootDir>/frontend/src/metabase-lib/lib/metadata/utils/ga-metadata.js", + "^d3-(.*)$": "d3-$1/dist/d3-$1" }, "testMatch": ["<rootDir>/frontend/test/**/*.tz.unit.spec.{js,ts,jsx,tsx}"], "modulePaths": ["<rootDir>/frontend/test", "<rootDir>/frontend/src"], diff --git a/jest.unit.conf.json b/jest.unit.conf.json index 1553b10c3aa93329b354e49418cbe49d2c12bc44..f6b2b7679b782d5b541799ede8ec1bf8139c0947 100644 --- a/jest.unit.conf.json +++ b/jest.unit.conf.json @@ -3,7 +3,8 @@ "\\.(css|less)$": "<rootDir>/frontend/test/__mocks__/styleMock.js", "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/frontend/test/__mocks__/fileMock.js", "^promise-loader\\?global\\!metabase-lib\\/lib\\/metadata\\/utils\\/ga-metadata$": "<rootDir>/frontend/src/metabase-lib/lib/metadata/utils/ga-metadata.js", - "ace/ext-searchbox": "<rootDir>/frontend/test/__mocks__/aceSearchBoxExtMock.js" + "ace/ext-searchbox": "<rootDir>/frontend/test/__mocks__/aceSearchBoxExtMock.js", + "^d3-(.*)$": "d3-$1/dist/d3-$1" }, "testPathIgnorePatterns": [ "<rootDir>/frontend/test/.*/.*.tz.unit.spec.{js,jsx,ts,tsx}" diff --git a/package.json b/package.json index b86b4cc3724af9b9964731a9c992893418c968d2..5f159d1bb142a86c0cf77cc3513d465566e1c15d 100644 --- a/package.json +++ b/package.json @@ -17,11 +17,16 @@ "@tippyjs/react": "^4.2.6", "@visx/axis": "1.8.0", "@visx/clip-path": "^2.1.0", + "@visx/event": "^2.6.0", "@visx/grid": "1.16.0", "@visx/group": "1.7.0", + "@visx/legend": "^2.10.0", + "@visx/mock-data": "^2.1.2", + "@visx/responsive": "^2.10.0", "@visx/scale": "1.7.0", "@visx/shape": "2.12.2", "@visx/text": "1.7.0", + "@visx/tooltip": "^2.10.0", "ace-builds": "^1.4.7", "classlist-polyfill": "^1.2.0", "classnames": "^2.1.3", @@ -33,7 +38,10 @@ "crossfilter": "^1.3.12", "cy2": "^1.3.0", "d3": "^3.5.17", - "d3-scale": "^2.1.0", + "d3-array": "^3.1.1", + "d3-scale": "^3.3.0", + "d3-shape": "^3.1.0", + "d3-time-format": "^4.1.0", "dc": "2.1.9", "diff": "^3.2.0", "formik": "^2.2.9", @@ -135,7 +143,9 @@ "@types/crossfilter": "^0.0.34", "@types/d3": "^3.5.46", "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", + "@types/d3-time-format": "^4.0.0", "@types/dc": "0.0.29", "@types/diff": "^3.5.4", "@types/eslint": "7.29.0", @@ -294,7 +304,7 @@ "build-hot": "yarn concurrently -n 'cljs,js' 'yarn build-hot:cljs' 'yarn build-hot:js'", "build-stats": "yarn && webpack --json > stats.json", "build-shared": "yarn && webpack --config webpack.shared.config.js", - "build-static-viz": "yarn && webpack --config webpack.static-viz.config.js", + "build-static-viz": "yarn && yarn build:cljs && webpack --config webpack.static-viz.config.js", "precommit": "lint-staged", "preinstall": "echo $npm_execpath | grep -q yarn || echo '\\033[0;33mSorry, npm is not supported. Please use Yarn (https://yarnpkg.com/).\\033[0m'", "prettier": "prettier --write '{enterprise/,}frontend/**/*.{js,jsx,ts,tsx,css}'", @@ -313,9 +323,9 @@ "test-visual-open": "percy exec -- yarn test-cypress-open", "test-visual": "yarn build && ./bin/build-for-test && yarn test-visual-run", "prepare": "husky install", - "storybook": "start-storybook -p 6006", - "build-storybook": "build-storybook", - "chromatic": "chromatic", + "storybook": "yarn build:cljs && start-storybook -p 6006", + "build-storybook": "yarn build:cljs && build-storybook", + "chromatic": "yarn build:cljs && chromatic", "docs-lint-links": "find docs -type f -name '*.md' -print0 | xargs -0 markdown-link-check --quiet --config .mlc_config.json" }, "lint-staged": { diff --git a/resources/frontend_shared/static_viz_interface.js b/resources/frontend_shared/static_viz_interface.js index fe4ffc2f1aeaea5bc1046cced293d019bb965f24..295b02dee2cd87f26777c7ad72d6b833520a17f1 100644 --- a/resources/frontend_shared/static_viz_interface.js +++ b/resources/frontend_shared/static_viz_interface.js @@ -14,6 +14,13 @@ function toJSMap(m) { return o; } +function row_chart(settings, data) { + return StaticViz.RenderChart("row", { + settings: JSON.parse(settings), + data: JSON.parse(data), + }); +} + function combo_chart(series, settings, colors) { // Thinking of combo as similar to multiple, although they're different in BE return StaticViz.RenderChart("combo-chart", { diff --git a/src/metabase/pulse/render.clj b/src/metabase/pulse/render.clj index c309715a1b9afb388677b63f8b0c406312db0e0b..967c49b2c8e3bcfce622b281a3d5ebb996a917ff 100644 --- a/src/metabase/pulse/render.clj +++ b/src/metabase/pulse/render.clj @@ -92,6 +92,7 @@ :area :bar :combo + :row :funnel :progress :gauge diff --git a/src/metabase/pulse/render/body.clj b/src/metabase/pulse/render/body.clj index 7ff27242f88297da6e5f9a3e4e7580093c5214db..52f8ebd4b2bd34d2f4c17e26b57fddd9edc5fb7e 100644 --- a/src/metabase/pulse/render/body.clj +++ b/src/metabase/pulse/render/body.clj @@ -709,6 +709,23 @@ [:img {:style (style/style {:display :block :width :100%}) :src (:image-src image-bundle)}]]})) +(s/defmethod render :row :- common/RenderedPulseCard + [_ render-type _timezone-id card _dashcard {:keys [rows cols] :as _data}] + (let [viz-settings (get-in card [:visualization_settings]) + data {:rows rows + :cols cols} + image-bundle (image-bundle/make-image-bundle + render-type + (js-svg/row-chart viz-settings data))] + {:attachments + (when image-bundle + (image-bundle/image-bundle->attachment image-bundle)) + + :content + [:div + [:img {:style (style/style {:display :block :width :100%}) + :src (:image-src image-bundle)}]]})) + (s/defmethod render :scalar :- common/RenderedPulseCard [_chart-type _render-type timezone-id _card dashcard {:keys [cols rows viz-settings]}] (let [viz-settings (merge viz-settings (:visualization_settings dashcard)) diff --git a/src/metabase/pulse/render/js_svg.clj b/src/metabase/pulse/render/js_svg.clj index 4fe4f60ff7950ccbc7c260415a0606729cb3a2bd..0a2971f5c43d395bf797d976cc1ea946260b1763 100644 --- a/src/metabase/pulse/render/js_svg.clj +++ b/src/metabase/pulse/render/js_svg.clj @@ -137,6 +137,14 @@ (json/generate-string (:colors settings))))] (svg-string->bytes svg-string))) +(defn row-chart + "Clojure entrypoint to render a row chart." + [settings data] + (let [svg-string (.asString (js/execute-fn-name @context "row_chart" + (json/generate-string settings) + (json/generate-string data)))] + (svg-string->bytes svg-string))) + (defn categorical-donut "Clojure entrypoint to render a categorical donut chart. Rows should be tuples of [category numeric-value]. Returns a byte array of a png file" diff --git a/webpack.static-viz.config.js b/webpack.static-viz.config.js index 93d15e53f8f9b1969bb91efd12b43be12a418c77..d978b014820bc724b74d576dd012abad22b22cc4 100644 --- a/webpack.static-viz.config.js +++ b/webpack.static-viz.config.js @@ -1,5 +1,7 @@ const SRC_PATH = __dirname + "/frontend/src/metabase"; const BUILD_PATH = __dirname + "/resources/frontend_client"; +const CLJS_SRC_PATH = __dirname + "/frontend/src/cljs"; +const LIB_SRC_PATH = __dirname + "/frontend/src/metabase-lib"; const BABEL_CONFIG = { cacheDirectory: process.env.BABEL_DISABLE_CACHE ? null : ".babel_cache", @@ -32,7 +34,7 @@ module.exports = { rules: [ { test: /\.(tsx?|jsx?)$/, - exclude: /node_modules/, + exclude: /node_modules|cljs/, use: [{ loader: "babel-loader", options: BABEL_CONFIG }], }, ], @@ -41,6 +43,8 @@ module.exports = { extensions: [".webpack.js", ".web.js", ".js", ".jsx", ".ts", ".tsx"], alias: { metabase: SRC_PATH, + cljs: CLJS_SRC_PATH, + "metabase-lib": LIB_SRC_PATH, }, }, }; diff --git a/yarn.lock b/yarn.lock index 1472e1ecf01d0c5549fe936f09bbd1ca6b24f0f7..c2086ea83ce9b81a6e75755e39ebc32895749531 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3404,6 +3404,11 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" +"@juggle/resize-observer@^3.3.1": + version "3.4.0" + resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz#08d6c5e20cf7e4cc02fd181c4b0c225cd31dbb60" + integrity sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA== + "@mdx-js/loader@^1.6.22": version "1.6.22" resolved "https://registry.yarnpkg.com/@mdx-js/loader/-/loader-1.6.22.tgz#d9e8fe7f8185ff13c9c8639c048b123e30d322c4" @@ -4909,11 +4914,21 @@ dependencies: "@types/d3-color" "^1" +"@types/d3-path@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-3.0.0.tgz#939e3a784ae4f80b1fde8098b91af1776ff1312b" + integrity sha512-0g/A+mZXgFkQxN3HniRDbXMN79K3CdTpLsevj+PXiTcb2hVyvkZUBg37StmgCQkaD84cUJ4uaDAWq7UJOQy2Tg== + "@types/d3-path@^1", "@types/d3-path@^1.0.8": version "1.0.9" resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-1.0.9.tgz#73526b150d14cd96e701597cbf346cfd1fd4a58c" integrity sha512-NaIeSIBiFgSC6IGUBjZWcscUJEq7vpVu7KthHN8eieTV9d9MqkSOZLH4chq1PmcKy06PNe3axLeKmRIyxJ+PZQ== +"@types/d3-random@^2.2.0": + version "2.2.1" + resolved "https://registry.yarnpkg.com/@types/d3-random/-/d3-random-2.2.1.tgz#551edbb71cb317dea2cf9c76ebe059d311eefacb" + integrity sha512-5vvxn6//poNeOxt1ZwC7QU//dG9QqABjy1T7fP/xmFHY95GnaOw3yABf29hiu5SR1Oo34XcpyHFbzod+vemQjA== + "@types/d3-scale@^3.2.1", "@types/d3-scale@^3.3.0": version "3.3.2" resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-3.3.2.tgz#18c94e90f4f1c6b1ee14a70f14bfca2bd1c61d06" @@ -4935,6 +4950,18 @@ dependencies: "@types/d3-path" "^1" +"@types/d3-shape@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.0.tgz#1d87a6ddcf28285ef1e5c278ca4bdbc0658f3505" + integrity sha512-jYIYxFFA9vrJ8Hd4Se83YI6XF+gzDL1aC5DCsldai4XYYiVNdhtpGbA/GM6iyQ8ayhSp3a148LY34hy7A4TxZA== + dependencies: + "@types/d3-path" "*" + +"@types/d3-time-format@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@types/d3-time-format/-/d3-time-format-4.0.0.tgz#ee7b6e798f8deb2d9640675f8811d0253aaa1946" + integrity sha512-yjfBUe6DJBsDin2BMIulhSHmr5qNR5Pxs17+oW4DoVPyVIXZ+m6bs7j1UVKP08Emv6jRmYrYqxYzO63mQxy1rw== + "@types/d3-time@*", "@types/d3-time@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.0.tgz#e1ac0f3e9e195135361fa1a1d62f795d87e6e819" @@ -5449,6 +5476,13 @@ dependencies: "@types/react" "*" +"@types/react-dom@*": + version "18.0.6" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.0.6.tgz#36652900024842b74607a17786b6662dd1e103a1" + integrity sha512-/5OFZgfIPSwy+YuIBP/FgJnQnsxhZhjjrnxudMddeblOouIodEQ75X14Rr4wGSG/bknL+Omy9iWlLo1u/9GzAA== + dependencies: + "@types/react" "*" + "@types/react-dom@~17.0.9": version "17.0.9" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.9.tgz#441a981da9d7be117042e1a6fd3dac4b30f55add" @@ -5901,6 +5935,15 @@ classnames "^2.2.5" prop-types "^15.6.0" +"@visx/bounds@2.10.0": + version "2.10.0" + resolved "https://registry.yarnpkg.com/@visx/bounds/-/bounds-2.10.0.tgz#cd0bdd7db924a5a2151174edce09f867aaeaf3af" + integrity sha512-rY7WFTIjQaXA8tFL45O2qbtSRkyF4yF75HiWz06F7BVmJ9UjF2qlomB3Y1z6gk6ZiFhwQ4zxABjOVjAQPLn7nQ== + dependencies: + "@types/react" "*" + "@types/react-dom" "*" + prop-types "^15.5.10" + "@visx/clip-path@^2.1.0": version "2.1.0" resolved "https://registry.yarnpkg.com/@visx/clip-path/-/clip-path-2.1.0.tgz#5730ebda167493a56d033c454453900ed0b4904b" @@ -5925,6 +5968,14 @@ "@types/d3-shape" "^1.3.1" d3-shape "^1.0.6" +"@visx/event@^2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@visx/event/-/event-2.6.0.tgz#0718eb1efabd5305cf659a153779c94ba4038996" + integrity sha512-WGp91g82s727g3NAnENF1ppC3ZAlvWg+Y+GG0WFg34NmmOZbvPI/PTOqTqZE3x6B8EUn8NJiMxRjxIMbi+IvRw== + dependencies: + "@types/react" "*" + "@visx/point" "2.6.0" + "@visx/grid@1.16.0": version "1.16.0" resolved "https://registry.yarnpkg.com/@visx/grid/-/grid-1.16.0.tgz#49eda6ee73cccc6ca559a6a847e2af97c38fc17a" @@ -5959,11 +6010,46 @@ classnames "^2.3.1" prop-types "^15.6.2" +"@visx/legend@^2.10.0": + version "2.10.0" + resolved "https://registry.yarnpkg.com/@visx/legend/-/legend-2.10.0.tgz#d561f86e4bc536f9d50e21d607e65f05640f73d5" + integrity sha512-OI8BYE6QQI9eXAng/C7UzuVw7d0fwlzrth6RmrdhlyT1K+BA3WpExapV+pDfwxu/tkEik8Ps5cZRV6HjX1/Mww== + dependencies: + "@types/react" "*" + "@visx/group" "2.10.0" + "@visx/scale" "2.2.2" + classnames "^2.3.1" + prop-types "^15.5.10" + +"@visx/mock-data@^2.1.2": + version "2.1.2" + resolved "https://registry.yarnpkg.com/@visx/mock-data/-/mock-data-2.1.2.tgz#e3e2130d5617694a34e62dcfa7ede3275db72ccd" + integrity sha512-6xUVP56tiPwVi3BxvoXPQzDYWG6iX2nnOlsHEYsHgK8gHq1r7AhjQtdbQUX7QF0QkmkJM0cW8TBjZ2e+dItB8Q== + dependencies: + "@types/d3-random" "^2.2.0" + d3-random "^2.2.2" + "@visx/point@1.7.0": version "1.7.0" resolved "https://registry.yarnpkg.com/@visx/point/-/point-1.7.0.tgz#1df3c3425eae464f498473bcdda2fcae05c8ecbe" integrity sha512-oaoY/HXYHhmpkkeKI4rBPmFtjHWtxSrIhZCVm1ipPoyQp3voJ8L6JD5eUIVmmaUCdUGUGwL1lFLnJiQ2p1Vlwg== +"@visx/point@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@visx/point/-/point-2.6.0.tgz#c4316ca409b5b829c5455f07118d8c14a92cc633" + integrity sha512-amBi7yMz4S2VSchlPdliznN41TuES64506ySI22DeKQ+mc1s1+BudlpnY90sM1EIw4xnqbKmrghTTGfy6SVqvQ== + +"@visx/responsive@^2.10.0": + version "2.10.0" + resolved "https://registry.yarnpkg.com/@visx/responsive/-/responsive-2.10.0.tgz#3e5c5853c7b2b33481e99a64678063cef717de0b" + integrity sha512-NssDPpuUYp7hqVISuYkKZ5zk6ob0++RdTIaUjRcUdyFEbvzb9+zIb8QToOkvI90L2EC/MY4Jx0NpDbEe79GpAw== + dependencies: + "@juggle/resize-observer" "^3.3.1" + "@types/lodash" "^4.14.172" + "@types/react" "*" + lodash "^4.17.21" + prop-types "^15.6.1" + "@visx/scale@1.14.0": version "1.14.0" resolved "https://registry.yarnpkg.com/@visx/scale/-/scale-1.14.0.tgz#622d274ec4f5e608de29d06cd6071892bb1e7587" @@ -6069,6 +6155,17 @@ prop-types "^15.7.2" reduce-css-calc "^1.3.0" +"@visx/tooltip@^2.10.0": + version "2.10.0" + resolved "https://registry.yarnpkg.com/@visx/tooltip/-/tooltip-2.10.0.tgz#86d6a720af573dc9853d86d99ae35052bb1629f3" + integrity sha512-6Zrd79MIEfyuLBcZ1ypSeAkpQc8oLRNB7FQnegzl3Lje4LK5lJtuf5ST0mwK6G2Uv+GlOW9REJ6VK4gfAGkq9A== + dependencies: + "@types/react" "*" + "@visx/bounds" "2.10.0" + classnames "^2.3.1" + prop-types "^15.5.10" + react-use-measure "^2.0.4" + "@vue/compiler-core@3.1.5": version "3.1.5" resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.1.5.tgz#298f905b6065d6d81ff63756f98c60876b393c87" @@ -9566,15 +9663,19 @@ d3-array@2, d3-array@^2.3.0: dependencies: internmap "^1.0.0" -d3-array@^1.2.0: - version "1.2.4" - resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f" - integrity sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw== +"d3-array@2 - 3": + version "3.2.0" + resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.0.tgz#15bf96cd9b7333e02eb8de8053d78962eafcff14" + integrity sha512-3yXFQo0oG3QCxbF06rMPFyGRMGJNS7NvsV1+2joOjbBE+9xvWQ8+GcMJAjRCzw06zQ3/arXeJgbPYcjUCuC+3g== + dependencies: + internmap "1 - 2" -d3-collection@1: - version "1.0.7" - resolved "https://registry.yarnpkg.com/d3-collection/-/d3-collection-1.0.7.tgz#349bd2aa9977db071091c13144d5e4f16b5b310e" - integrity sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A== +d3-array@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.1.1.tgz#7797eb53ead6b9083c75a45a681e93fc41bc468c" + integrity sha512-33qQ+ZoZlli19IFiQx4QEpf2CBEayMRzhlisJHSCsSUbDXv6ZishqS1x7uFVClKG4Wr7rZVHvaAttoLow6GqdQ== + dependencies: + internmap "1 - 2" d3-color@1: version "1.4.1" @@ -9586,23 +9687,11 @@ d3-color@1: resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-2.0.0.tgz#8d625cab42ed9b8f601a1760a389f7ea9189d62e" integrity sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ== -d3-format@1: - version "1.4.5" - resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.4.5.tgz#374f2ba1320e3717eb74a9356c67daee17a7edb4" - integrity sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ== - "d3-format@1 - 2": version "2.0.0" resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-2.0.0.tgz#a10bcc0f986c372b729ba447382413aabf5b0767" integrity sha512-Ab3S6XuE/Q+flY96HXT0jOXcM4EAClYFnRGY5zsjRGNy6qCYrQsMffs7cV5Q9xejb35zxW5hf/guKw34kvIKsA== -d3-interpolate@1, d3-interpolate@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-1.4.0.tgz#526e79e2d80daa383f9e0c1c1c7dcc0f0583e987" - integrity sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA== - dependencies: - d3-color "1" - "d3-interpolate@1.2.0 - 2": version "2.0.1" resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-2.0.1.tgz#98be499cfb8a3b94d4ff616900501a64abc91163" @@ -9610,22 +9699,27 @@ d3-interpolate@1, d3-interpolate@^1.4.0: dependencies: d3-color "1 - 2" +d3-interpolate@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-1.4.0.tgz#526e79e2d80daa383f9e0c1c1c7dcc0f0583e987" + integrity sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA== + dependencies: + d3-color "1" + d3-path@1, d3-path@^1.0.5: version "1.0.9" resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf" integrity sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg== -d3-scale@^2.1.0: +"d3-path@1 - 3": + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-3.0.1.tgz#f09dec0aaffd770b7995f1a399152bf93052321e" + integrity sha512-gq6gZom9AFZby0YLduxT1qmrp4xpBA1YZr19OI717WIdKE2OM5ETq5qrHLb301IgxhLwcuxvGZVLeeWc/k1I6w== + +d3-random@^2.2.2: version "2.2.2" - resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-2.2.2.tgz#4e880e0b2745acaaddd3ede26a9e908a9e17b81f" - integrity sha512-LbeEvGgIb8UMcAa0EATLNX0lelKWGYDQiPdHj+gLblGVhGLyNbaCn3EvrJf0A3Y/uOOU5aD6MTh5ZFCdEwGiCw== - dependencies: - d3-array "^1.2.0" - d3-collection "1" - d3-format "1" - d3-interpolate "1" - d3-time "1" - d3-time-format "2" + resolved "https://registry.yarnpkg.com/d3-random/-/d3-random-2.2.2.tgz#5eebd209ef4e45a2b362b019c1fb21c2c98cbb6e" + integrity sha512-0D9P8TRj6qDAtHhRQn6EfdOtHMfsUWanl3yb/84C4DqpZ+VsgfI5iTVRNRbELCfNvRfpMr8OrqqUTQ6ANGCijw== d3-scale@^3.2.3, d3-scale@^3.3.0: version "3.3.0" @@ -9645,12 +9739,12 @@ d3-shape@^1.0.6, d3-shape@^1.2.0: dependencies: d3-path "1" -d3-time-format@2: - version "2.3.0" - resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-2.3.0.tgz#107bdc028667788a8924ba040faf1fbccd5a7850" - integrity sha512-guv6b2H37s2Uq/GefleCDtbe0XZAuy7Wa49VGkPVPMfLL9qObgBST3lEHJBMUp8S7NdLQAGIvr2KXk8Hc98iKQ== +d3-shape@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.1.0.tgz#c8a495652d83ea6f524e482fca57aa3f8bc32556" + integrity sha512-tGDh1Muf8kWjEDT/LswZJ8WF85yDZLvVJpYU9Nq+8+yW1Z5enxrmXOhTArlkaElU+CTn0OTVNli+/i+HP45QEQ== dependencies: - d3-time "1" + d3-path "1 - 3" "d3-time-format@2 - 3": version "3.0.0" @@ -9659,10 +9753,12 @@ d3-time-format@2: dependencies: d3-time "1 - 2" -d3-time@1, d3-time@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-1.1.0.tgz#b1e19d307dae9c900b7e5b25ffc5dcc249a8a0f1" - integrity sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA== +d3-time-format@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a" + integrity sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg== + dependencies: + d3-time "1 - 3" "d3-time@1 - 2", d3-time@^2.1.1: version "2.1.1" @@ -9671,6 +9767,18 @@ d3-time@1, d3-time@^1.1.0: dependencies: d3-array "2" +"d3-time@1 - 3": + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-3.0.0.tgz#65972cb98ae2d4954ef5c932e8704061335d4975" + integrity sha512-zmV3lRnlaLI08y9IMRXSDshQb5Nj77smnfpnd2LrBa/2K281Jijactokeak14QacHs/kKq0AQ121nidNYlarbQ== + dependencies: + d3-array "2 - 3" + +d3-time@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-1.1.0.tgz#b1e19d307dae9c900b7e5b25ffc5dcc249a8a0f1" + integrity sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA== + d3@^3, d3@^3.5.17: version "3.5.17" resolved "https://registry.yarnpkg.com/d3/-/d3-3.5.17.tgz#bc46748004378b21a360c9fc7cf5231790762fb8" @@ -9730,6 +9838,11 @@ de-indent@^1.0.2: resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d" integrity sha1-sgOOhG3DO6pXlhKNCAS0VbjB4h0= +debounce@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5" + integrity sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug== + debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.8, debug@^2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" @@ -13238,6 +13351,11 @@ internal-slot@^1.0.3: has "^1.0.3" side-channel "^1.0.4" +"internmap@1 - 2": + version "2.0.3" + resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009" + integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg== + internmap@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/internmap/-/internmap-1.0.1.tgz#0017cc8a3b99605f0302f2b198d272e015e5df95" @@ -19102,6 +19220,13 @@ react-transition-group@1: prop-types "^15.5.6" warning "^3.0.0" +react-use-measure@^2.0.4: + version "2.1.1" + resolved "https://registry.yarnpkg.com/react-use-measure/-/react-use-measure-2.1.1.tgz#5824537f4ee01c9469c45d5f7a8446177c6cc4ba" + integrity sha512-nocZhN26cproIiIduswYpV5y5lQpSQS1y/4KuvUCjSKmw7ZWIS/+g3aFnX3WdBkyuGUtTLif3UTqnLLhbDoQig== + dependencies: + debounce "^1.2.1" + react-virtualized@^9.7.2: version "9.22.2" resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.22.2.tgz#217a870bad91e5438f46f01a009e1d8ce1060a5a"