Skip to content
Snippets Groups Projects
Unverified Commit 23837a6f authored by Aleksandr Lesnenko's avatar Aleksandr Lesnenko Committed by GitHub
Browse files

Static row chart (#25829)

* support static row chart on the backend

* update and add types

* update types

* add chart columns helper, dataset grouping

* add viz settings helpers

* fix text measuring

* add generic row chart component for static and dynamic rendering

* update deps

* fix jest config for d3

* allow importing cljs in static viz

* add a hook for getting chart columns and series

* add a static row chart

* build cljs before static viz

* specs

* add stories, moved isomorphic row chart component to the shared folder

* update import

* fix backend merge

* remove an extra line

* one more extra line

* exclude unused props from the row chart

* cleanup

* review

* fix types

* fix log scale

* add visual spec, fix goal label truncate

* review

* fix mock path

* fix specs
parent a1e98eb3
No related merge requests found
Showing
with 448 additions and 35 deletions
......@@ -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;
};
......
......@@ -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;
......
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,
},
......
......@@ -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;
......
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;
};
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;
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"],
// },
export { default } from "./RowChart";
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"),
},
};
};
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);
};
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,
};
};
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: {
......
import { Margin } from "../types";
import { Margin } from "metabase/visualizations/shared/types/layout";
export const calculateBounds = (
margin: Margin,
......
......@@ -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,
......
......@@ -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;
......
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:
......
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;
......@@ -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}`;
......
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 = {
......
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,
};
};
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment