Compare commits

...

1 Commits

Author SHA1 Message Date
Andre Fong
bfa235888f feat(table): add chart visualization to AG Grid table column (#36344)
Merging POC into the `aggrid-table-charts` feature branch. Follow-up commits will address CodeAnt findings (stale-value state in ChartColumnsControl, null→0 coercion in BarChartRenderer, unreachable Array.isArray guard in SparklineRenderer, rgbToHex shape mismatch in chartRenderers).

Co-authored-by: Andre Fong <andre-fong@users.noreply.github.com>
2026-04-27 11:43:43 -04:00
25 changed files with 1481 additions and 63 deletions

View File

@@ -133,6 +133,7 @@ export enum GenericDataType {
String = 1,
Temporal = 2,
Boolean = 3,
Chart = 4, // Only for frontend use for now
}
/**

View File

@@ -28,6 +28,7 @@ import {
FieldBinaryOutlined,
FieldStringOutlined,
NumberOutlined,
LineChartOutlined,
} from '@ant-design/icons';
export type ColumnLabelExtendedType = 'expression' | '';
@@ -69,6 +70,8 @@ export function ColumnTypeLabel({ type }: ColumnTypeLabelProps) {
typeIcon = <FieldBinaryOutlined aria-label={t('boolean type icon')} />;
} else if (type === GenericDataType.Temporal) {
typeIcon = <ClockCircleOutlined aria-label={t('temporal type icon')} />;
} else if (type === GenericDataType.Chart) {
typeIcon = <LineChartOutlined aria-label={t('chart type icon')} />;
}
return <TypeIconWrapper>{typeIcon}</TypeIconWrapper>;

View File

@@ -606,4 +606,15 @@ export type ControlFormItemSpec<T extends ControlType = ControlType> = {
value?: Currency;
defaultValue?: Currency;
}
: {});
: T extends 'ColorPickerControl'
? {
controlType: 'ColorPickerControl';
value?: { r: number; g: number; b: number; a?: number };
defaultValue?: {
r: number;
g: number;
b: number;
a?: number;
};
}
: {});

View File

@@ -0,0 +1,272 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ReactElement, useMemo } from 'react';
import { formatNumber, formatTime } from '@superset-ui/core';
import { useTheme } from '@apache-superset/core/ui';
import { GridRows } from '@visx/grid';
import { scaleLinear } from '@visx/scale';
import {
Axis,
LineSeries,
BarSeries,
AreaSeries,
Tooltip,
XYChart,
buildChartTheme,
type SeriesProps,
AxisScale,
} from '@visx/xychart';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import {
getSparklineTextWidth,
createYScaleConfig,
transformChartData,
} from '../../utils/sparklineHelpers';
dayjs.extend(utc);
type SparkType = 'line' | 'bar' | 'area';
interface Entry {
time: string;
[key: string]: any;
}
interface SparklineCellProps {
ariaLabel: string;
dataKey: string;
className?: string;
data: Array<number | null>;
entries: Entry[];
height?: number;
numberFormat?: string;
dateFormat?: string;
showYAxis?: boolean;
width?: number;
yAxisBounds?: [number | undefined, number | undefined];
sparkType?: SparkType;
color?: string;
strokeWidth?: number;
showPoints?: boolean;
}
const MARGIN = {
top: 8,
right: 8,
bottom: 8,
left: 8,
} as const;
const SparklineCell = ({
ariaLabel,
dataKey,
data,
width = 300,
height = 50,
numberFormat = '',
dateFormat = '',
yAxisBounds = [undefined, undefined],
showYAxis = false,
entries = [],
sparkType = 'line',
color,
strokeWidth = 1,
showPoints = true,
}: SparklineCellProps): ReactElement => {
const theme = useTheme();
const finalSeriesColor = color || theme.colorText;
const xyTheme = useMemo(
() =>
buildChartTheme({
backgroundColor: `${theme.colorBgContainer}`,
colors: [`${finalSeriesColor}`],
gridColor: `${theme.colorSplit}`,
gridColorDark: `${theme.colorBorder}`,
tickLength: 6,
}),
[theme, finalSeriesColor],
);
const validData = useMemo(
() => data.filter((value): value is number => value !== null),
[data],
);
const chartData = useMemo(() => transformChartData(data), [data]);
const { yScaleConfig, min, max } = useMemo(
() => createYScaleConfig(validData, yAxisBounds),
[validData, yAxisBounds],
);
const { margin } = useMemo(() => {
if (!showYAxis)
return {
margin: MARGIN,
minLabel: '',
maxLabel: '',
};
const minLbl = formatNumber(numberFormat, min);
const maxLbl = formatNumber(numberFormat, max);
const labelLength = Math.max(
getSparklineTextWidth(minLbl),
getSparklineTextWidth(maxLbl),
);
return {
margin: {
...MARGIN,
right: MARGIN.right + labelLength,
},
minLabel: minLbl,
maxLabel: maxLbl,
};
}, [showYAxis, numberFormat, min, max]);
const innerWidth = width - margin.left - margin.right;
const xAccessor = (d: { x: number; y: number }) => d.x;
const yAccessor = (d: { x: number; y: number }) => d.y;
const chartSeriesMap: Record<
SparkType,
(props: SeriesProps<AxisScale, AxisScale, object>) => JSX.Element
> = {
line: LineSeries,
bar: BarSeries,
area: AreaSeries,
};
const SeriesComponent = chartSeriesMap[sparkType] || LineSeries;
if (validData.length === 0) return <div style={{ width, height }} />;
return (
<>
<XYChart
accessibilityLabel={ariaLabel}
width={width}
height={height}
margin={margin}
yScale={{
...yScaleConfig,
}}
xScale={{ type: 'band', paddingInner: 0.5, paddingOuter: 0.1 }}
theme={xyTheme}
>
{showYAxis && (
<>
<Axis
hideAxisLine
hideTicks
numTicks={2}
orientation="right"
tickFormat={(value: number) => formatNumber(numberFormat, value)}
tickValues={[min, max]}
/>
<GridRows
left={margin.left}
scale={scaleLinear({
range: [height - margin.top, margin.bottom],
domain: [min, max],
})}
width={innerWidth}
strokeDasharray="3 3"
stroke={theme.colorSplit}
tickValues={[min, max]}
/>
</>
)}
<SeriesComponent
data={chartData}
dataKey={dataKey}
xAccessor={xAccessor}
yAccessor={yAccessor}
{...(sparkType === 'line' || sparkType === 'area'
? { strokeWidth: strokeWidth }
: {})}
/>
{showPoints && (
<Tooltip
glyphStyle={{ strokeWidth: 1 }}
showDatumGlyph
showVerticalCrosshair
snapTooltipToDatumX
snapTooltipToDatumY
verticalCrosshairStyle={{
stroke: theme.colorText,
strokeDasharray: '3 3',
strokeWidth: 1,
}}
renderTooltip={({ tooltipData }) => {
const idx = tooltipData?.datumByKey[dataKey]?.index;
if (idx === undefined || !entries[idx]) {
return null;
}
const value = data[idx] ?? 0;
const timeValue = entries[idx]?.time;
return (
<div
css={() => ({
color: theme.colorText,
padding: '8px',
})}
>
<strong
css={() => ({
color: theme.colorText,
display: 'block',
marginBottom: '4px',
})}
>
{formatNumber(numberFormat, value)}
</strong>
{timeValue && (
<div
css={() => ({
color: theme.colorTextSecondary,
fontSize: '12px',
})}
>
{formatTime(dateFormat, dayjs.utc(timeValue).toDate())}
</div>
)}
</div>
);
}}
/>
)}
</XYChart>
<style>
{`
svg:not(:root) {
overflow: visible;
}
`}
</style>
</>
);
};
export default SparklineCell;

View File

@@ -0,0 +1,19 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export { default } from './SparklineCell';

View File

@@ -314,6 +314,21 @@ const config: ControlPanelConfig = {
config: percentMetricsControl,
},
],
[
{
name: 'chart_columns',
config: {
type: 'ChartColumnsControl',
label: t('Chart columns'),
description: t(
'Add columns that display charts for each row. The charts will visualize the metrics selected above for each row. To control which metrics are visualized, select the desired metrics in the "Metrics" control. Best used when the metric columns make sense to be visualized in a chart.',
),
default: [],
visibility: isAggMode,
resetOnHide: false,
},
},
],
['adhoc_filters'],
[
{
@@ -578,6 +593,20 @@ const config: ControlPanelConfig = {
colnames = updatedColnames;
coltypes = updatedColtypes;
}
const chartColumns =
explore?.controls?.chart_columns?.value || [];
if (Array.isArray(chartColumns)) {
chartColumns.forEach(
(col: { key: string; label: string }) => {
if (!colnames.includes(col.label)) {
colnames.push(col.label);
coltypes.push(GenericDataType.Chart);
}
},
);
}
return {
columnsPropsObject: {
colnames,

View File

@@ -0,0 +1,197 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useMemo } from 'react';
import { styled, useTheme } from '@apache-superset/core/ui';
import { formatNumber } from '@superset-ui/core';
import { CustomCellRendererProps } from '@superset-ui/core/components/ThemedAgGridReact';
import { XYChart, BarSeries, buildChartTheme, Axis } from '@visx/xychart';
import { GridRows } from '@visx/grid';
import { scaleLinear } from '@visx/scale';
import { InputColumn } from '../types';
import { parseArrayValue } from '../utils/formatValue';
import {
getSparklineTextWidth,
createYScaleConfig,
} from '../utils/sparklineHelpers';
import { rgbToHex } from '../utils/chartRenderers';
interface VisxData {
x: number;
y: number;
}
function transformBarChartData(dataArray: Array<number | null>): VisxData[] {
return dataArray
.filter((value): value is number => value !== null)
.map((y, x) => ({ x, y }));
}
const CellContainer = styled.div<{ align?: string }>`
display: flex;
align-items: center;
justify-content: ${({ align }) => align || 'left'};
width: 100%;
height: 100%;
padding: 0;
box-sizing: border-box;
`;
const MARGIN = {
top: 8,
right: 8,
bottom: 8,
left: 8,
} as const;
export const BarChartRenderer = (
params: CustomCellRendererProps & {
col: InputColumn;
},
) => {
const { data, col } = params;
const value = parseArrayValue(data);
const theme = useTheme();
if (!Array.isArray(value)) {
return <CellContainer>N/A</CellContainer>;
}
// Chart configuration is now processed in transformProps with proper defaults
const chartConfig = col?.config || {};
const {
width = 300, // Default from transformProps
height = 60, // Default from transformProps
color,
showValues = true, // Default from transformProps
} = chartConfig;
const dataKey = col?.metricName || col?.key || 'value';
const ariaLabel = `Bar chart for ${col?.label || dataKey}`;
const numberFormat = '.2f';
const validData = useMemo(() => value, [value]);
const chartData = useMemo(
() => transformBarChartData(validData),
[validData],
);
const { min, max } = useMemo(
() => createYScaleConfig(validData, [undefined, undefined]),
[validData],
);
const { margin } = useMemo(() => {
if (!showValues)
return {
margin: MARGIN,
minLabel: '',
maxLabel: '',
};
const minLbl = formatNumber(numberFormat, min);
const maxLbl = formatNumber(numberFormat, max);
const labelLength = Math.max(
getSparklineTextWidth(minLbl),
getSparklineTextWidth(maxLbl),
);
return {
margin: {
...MARGIN,
right: MARGIN.right + labelLength,
},
minLabel: minLbl,
maxLabel: maxLbl,
};
}, [showValues, numberFormat, min, max]);
const innerWidth = width - margin.left - margin.right;
const finalSeriesColor =
typeof color === 'object' ? rgbToHex(color) : color || theme.colorText;
const xyTheme = useMemo(
() =>
buildChartTheme({
backgroundColor: 'transparent',
colors: [`${finalSeriesColor}`],
gridColor: theme.colorSplit,
gridColorDark: theme.colorBorder,
tickLength: 6,
}),
[finalSeriesColor, theme],
);
return (
<CellContainer align="left">
<div style={{ width, height, overflow: 'hidden' }}>
<XYChart
accessibilityLabel={ariaLabel}
width={width}
height={height}
margin={margin}
xScale={{
type: 'band',
paddingInner: 0.5,
paddingOuter: 0.1,
}}
yScale={{
type: 'linear',
domain: [min, max],
// Don't set range - let XYChart handle it
}}
theme={xyTheme}
>
{showValues && (
<>
<Axis
hideAxisLine
hideTicks
numTicks={2}
orientation="right"
tickFormat={(value: number) =>
formatNumber(numberFormat, value)
}
tickValues={[min, max]}
/>
<GridRows
left={margin.left}
scale={scaleLinear({
range: [height - margin.top, margin.bottom],
domain: [min, max],
})}
width={innerWidth}
strokeDasharray="3 3"
stroke={theme.colorSplit}
tickValues={[min, max]}
/>
</>
)}
<BarSeries
dataKey={dataKey}
data={chartData}
xAccessor={d => (d as VisxData).x}
yAccessor={d => (d as VisxData).y}
colorAccessor={() => finalSeriesColor}
/>
</XYChart>
</div>
</CellContainer>
);
};

View File

@@ -0,0 +1,84 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { styled, useTheme } from '@apache-superset/core/ui';
import { CustomCellRendererProps } from '@superset-ui/core/components/ThemedAgGridReact';
import { InputColumn } from '../types';
import SparklineCell from '../components/SparklineCell';
import { parseArrayValue } from '../utils/formatValue';
import { rgbToHex } from '../utils/chartRenderers';
const CellContainer = styled.div<{ align?: string }>`
display: flex;
align-items: center;
justify-content: ${({ align }) => align || 'left'};
width: 100%;
`;
export const SparklineRenderer = (
params: CustomCellRendererProps & {
col: InputColumn;
},
) => {
const { data, col } = params;
const value = parseArrayValue(data);
// Chart configuration is now processed in transformProps with proper defaults
const chartConfig = col?.config || {};
const {
width = 100, // Default from transformProps
height = 60, // Default from transformProps
color,
strokeWidth = 1.5, // Default from transformProps
showValues = true, // Default from transformProps
showPoints = true, // Default from transformProps
} = chartConfig;
if (!Array.isArray(value)) {
return <CellContainer>N/A</CellContainer>;
}
const dataKey = col?.metricName || col?.key || 'value';
const ariaLabel = `Sparkline chart for ${col?.label || dataKey}`;
const theme = useTheme();
const chartColor =
color && typeof color === 'object'
? rgbToHex(color)
: color || theme.colorText;
return (
<CellContainer>
<SparklineCell
ariaLabel={ariaLabel}
dataKey={dataKey}
data={value}
entries={value.map(_ => ({ time: '' }))}
width={width}
height={height}
numberFormat=".2f"
dateFormat={''}
showYAxis={showValues}
yAxisBounds={[undefined, undefined]}
sparkType="line"
color={chartColor}
strokeWidth={strokeWidth}
showPoints={showPoints}
/>
</CellContainer>
);
};

View File

@@ -224,6 +224,8 @@ const processComparisonColumns = (
datasource: { columnFormats, currencyFormats },
rawFormData: { column_config: columnConfig = {} },
} = props;
// Use processed config if provided, otherwise fallback to raw config
const finalColumnConfig = columnConfig;
const savedFormat = columnFormats?.[col.key];
const savedCurrency = currencyFormats?.[col.key];
const originalLabel = col.label;
@@ -239,11 +241,15 @@ const processComparisonColumns = (
metricName: col.key,
label: t('Main'),
key: `${t('Main')} ${col.key}`,
config: getComparisonColConfig(t('Main'), col.key, columnConfig),
config: getComparisonColConfig(
t('Main'),
col.key,
finalColumnConfig,
),
formatter: getComparisonColFormatter(
t('Main'),
col,
columnConfig,
finalColumnConfig,
savedFormat,
savedCurrency,
),
@@ -254,11 +260,11 @@ const processComparisonColumns = (
metricName: col.key,
label: `#`,
key: `# ${col.key}`,
config: getComparisonColConfig(`#`, col.key, columnConfig),
config: getComparisonColConfig(`#`, col.key, finalColumnConfig),
formatter: getComparisonColFormatter(
`#`,
col,
columnConfig,
finalColumnConfig,
savedFormat,
savedCurrency,
),
@@ -269,11 +275,11 @@ const processComparisonColumns = (
metricName: col.key,
label: ``,
key: `${col.key}`,
config: getComparisonColConfig(``, col.key, columnConfig),
config: getComparisonColConfig(``, col.key, finalColumnConfig),
formatter: getComparisonColFormatter(
``,
col,
columnConfig,
finalColumnConfig,
savedFormat,
savedCurrency,
),
@@ -284,11 +290,11 @@ const processComparisonColumns = (
metricName: col.key,
label: `%`,
key: `% ${col.key}`,
config: getComparisonColConfig(`%`, col.key, columnConfig),
config: getComparisonColConfig(`%`, col.key, finalColumnConfig),
formatter: getComparisonColFormatter(
`%`,
col,
columnConfig,
finalColumnConfig,
savedFormat,
savedCurrency,
),
@@ -348,6 +354,7 @@ const processColumns = memoizeOne(function processColumns(
},
queriesData,
} = props;
const granularity = extractTimegrain(props.rawFormData);
const { data: records, colnames, coltypes } = queriesData[0] || {};
// convert `metrics` and `percentMetrics` to the key names in `data.records`
@@ -367,7 +374,19 @@ const processColumns = memoizeOne(function processColumns(
)
.map((key: string, i) => {
const dataType = coltypes[i];
const config = columnConfig[key] || {};
const rawConfig = columnConfig[key] || {};
const config = { ...rawConfig };
// if the column is a chart, apply default config
if (dataType === GenericDataType.Chart) {
config.chartType = config.chartType ?? 'sparkline';
config.width = config.width ?? config.columnWidth ?? 100;
config.height = config.height ?? 60;
config.color = config.color ?? { r: 0, g: 255, b: 0, a: 1 };
config.strokeWidth = config.strokeWidth ?? 1.5;
config.showValues = config.showValues ?? true;
config.showPoints = config.showPoints ?? true;
}
// for the purpose of presentation, only numeric values are treated as metrics
// because users can also add things like `MAX(str_col)` as a metric.
const isMetric = metricsSet.has(key) && isNumeric(key, records);
@@ -620,6 +639,9 @@ const transformProps = (
});
});
// Process columns
const [, percentMetrics, columns] = processColumns(chartProps);
const getBasicColorFormatterForColumn = (
originalData: DataRecord[] | undefined,
originalColumns: DataColumnMeta[],
@@ -645,8 +667,6 @@ const transformProps = (
hasServerPageLengthChanged = true;
}
const [, percentMetrics, columns] = processColumns(chartProps);
const timeGrain = extractTimegrain(formData);
const comparisonSuffix = isUsingTimeComparison

View File

@@ -43,6 +43,7 @@ import {
IHeaderParams,
CustomCellRendererProps,
} from '@superset-ui/core/components/ThemedAgGridReact';
import { type RGBColor } from '@superset-ui/core/components';
export type CustomFormatter = (value: DataRecordValue) => string;
@@ -60,6 +61,14 @@ export type TableColumnConfig = {
visible?: boolean;
customColumnName?: string;
displayTypeIcon?: boolean;
// Chart renderer configuration
chartType?: 'sparkline' | 'minibar';
width?: number;
height?: number;
color?: RGBColor;
strokeWidth?: number;
showValues?: boolean;
showPoints?: boolean;
};
export interface DataColumnMeta {

View File

@@ -0,0 +1,66 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { SparklineRenderer } from '../renderers/SparklineRenderer';
import { BarChartRenderer } from '../renderers/BarChartRenderer';
import type { InputColumn } from '../types';
import { GenericDataType } from '@apache-superset/core/api/core';
import { type RGBColor } from '@superset-ui/core/components';
/**
* Registry of available chart renderers
* Maps chartType strings to their corresponding renderer components
*/
const CHART_RENDERERS = {
sparkline: SparklineRenderer,
minibar: BarChartRenderer, // Map minibar to BarChartRenderer
};
/**
* Factory function to get the appropriate chart renderer based on chart type
* @param chartType - The type of chart renderer to retrieve
* @returns The chart renderer component function
*/
export const getChartRenderer = (chartType: string) =>
CHART_RENDERERS[chartType as keyof typeof CHART_RENDERERS] ||
CHART_RENDERERS.sparkline;
/**
* Determines if a column should use a chart renderer
* @param col The column definition
* @returns True if the column is a chart type
*/
export const shouldUseChartRenderer = (col: InputColumn): boolean =>
col.dataType === GenericDataType.Chart;
export const rgbToHex = (rgb: RGBColor): string => {
const { r, g, b, a = 1 } = rgb;
const toHex = (value: number) => {
const hex = Math.round(value).toString(16);
return hex.length === 1 ? `0${hex}` : hex;
};
const hexColor = `#${toHex(r)}${toHex(g)}${toHex(b)}`;
if (a !== undefined && a !== 1) {
return `${hexColor}${toHex(Math.round(a * 255))}`;
}
return hexColor;
};

View File

@@ -116,3 +116,33 @@ export const valueGetter = (params: ValueGetterParams, col: InputColumn) => {
}
return '';
};
/**
* Extracts numeric values from either an array or an object.
* - If value is an array: filters out non-numeric values
* - If value is an object: extracts all numeric property values
* @param value - Array or object containing numeric data
* @returns Array of numeric values
*/
export const parseArrayValue = (
value:
| (number | string | boolean | object | null | undefined)[]
| Record<string, number | string | boolean | object | null | undefined>
| null
| undefined,
): number[] => {
// Handle array input - filter to only numeric values
if (Array.isArray(value)) {
return value.filter((item): item is number => typeof item === 'number');
}
// Handle object input - extract all numeric property values
if (typeof value === 'object' && value !== null) {
return Object.values(value).filter(
(item): item is number => typeof item === 'number',
);
}
// Return empty array for other types
return [];
};

View File

@@ -0,0 +1,102 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { getTextDimension } from '@superset-ui/core';
import { LinearScaleConfig } from '@visx/scale';
import { AxisScaleOutput } from '@visx/axis';
const TEXT_STYLE = {
fontSize: '12px',
fontWeight: 200,
letterSpacing: 0.4,
} as const;
/**
* Calculates the width needed for text in the sparkline
*/
export function getSparklineTextWidth(text: string): number {
return getTextDimension({ text, style: TEXT_STYLE }).width + 5;
}
/**
* Validates if a value can be used as a bound
*/
export function isValidBoundValue(value?: number | string): value is number {
return (
value !== null &&
value !== undefined &&
value !== '' &&
typeof value === 'number' &&
!Number.isNaN(value)
);
}
/**
* Calculates min and max values from valid data points
*/
export function getDataBounds(validData: number[]): [number, number] {
if (validData.length === 0) {
return [0, 0];
}
const min = Math.min(...validData);
const max = Math.max(...validData);
return [min, max];
}
/**
* Creates Y scale configuration based on data and bounds
*/
export function createYScaleConfig(
validData: number[],
yAxisBounds?: [number | undefined, number | undefined],
): {
yScaleConfig: LinearScaleConfig<AxisScaleOutput>;
min: number;
max: number;
} {
const [dataMin, dataMax] = getDataBounds(validData);
const [minBound, maxBound] = yAxisBounds || [undefined, undefined];
const hasMinBound = isValidBoundValue(minBound);
const hasMaxBound = isValidBoundValue(maxBound);
const finalMin = hasMinBound ? minBound! : dataMin;
const finalMax = hasMaxBound ? maxBound! : dataMax;
const config: LinearScaleConfig<AxisScaleOutput> = {
type: 'linear',
zero: hasMinBound && minBound! <= 0,
domain: [finalMin, finalMax],
};
return {
yScaleConfig: config,
min: finalMin,
max: finalMax,
};
}
/**
* Transforms raw data into chart data points
*/
export function transformChartData(
data: Array<number | null>,
): Array<{ x: number; y: number }> {
return data.map((num, idx) => ({ x: idx, y: num ?? 0 }));
}

View File

@@ -34,6 +34,7 @@ import dateFilterComparator from './dateFilterComparator';
import { getAggFunc } from './getAggFunc';
import { TextCellRenderer } from '../renderers/TextCellRenderer';
import { NumericCellRenderer } from '../renderers/NumericCellRenderer';
import { getChartRenderer, shouldUseChartRenderer } from './chartRenderers';
import CustomHeader from '../AgGridTable/components/CustomHeader';
import { valueFormatter, valueGetter } from './formatValue';
import getCellStyle from './getCellStyle';
@@ -239,8 +240,14 @@ export const useColDefs = ({
'last',
],
}),
cellRenderer: (p: CellRendererProps) =>
isTextColumn ? TextCellRenderer(p) : NumericCellRenderer(p),
cellRenderer: (p: CellRendererProps) => {
// Check if column should use a chart renderer
if (shouldUseChartRenderer(col)) {
return getChartRenderer(col.config?.chartType || 'sparkline')(p);
}
// Fall back to existing text/numeric renderer logic
return isTextColumn ? TextCellRenderer(p) : NumericCellRenderer(p);
},
cellRendererParams: {
allowRenderHtml: true,
columns,

View File

@@ -0,0 +1,85 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen } from '@testing-library/react';
import { BarChartRenderer } from '../src/renderers/BarChartRenderer';
import { ThemeProvider, supersetTheme } from '@apache-superset/core/ui';
import '@testing-library/jest-dom';
const mockData = [150, 152, 155, 153, null, 162, 165, 163, 168, 170];
const createMockParams = {
valueFormatted: undefined,
node: {
rowIndex: 0,
rowPinned: undefined,
id: 'row-0',
},
col: {
key: 'test-column',
label: 'Test Column',
metricName: 'test_metric',
config: {
width: 400,
height: 10,
color: '#28d8c9ff',
showValues: false,
horizontalAlign: 'left',
},
},
colDef: {},
data: mockData,
rowIndex: 0,
};
const renderWithTheme = (component: React.ReactElement) =>
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
test('should render BarChartRenderer', () => {
renderWithTheme(<BarChartRenderer {...(createMockParams as any)} />);
const chart = screen.getByLabelText('Bar chart for Test Column');
expect(chart).toBeInTheDocument();
});
test('renders bar chart with correct dimensions, color, and strokeWidth', () => {
renderWithTheme(<BarChartRenderer {...(createMockParams as any)} />);
const svg = document.querySelector('svg');
expect(svg).toHaveAttribute('width', '400');
expect(svg).toHaveAttribute('height', '10');
const coloredElement = document.querySelector('[fill="#28d8c9ff"]');
expect(coloredElement).toBeInTheDocument();
});
test('should render with y-axis when showValues is true', () => {
const paramsWithValues = { ...createMockParams };
paramsWithValues.col.config.showValues = true;
renderWithTheme(<BarChartRenderer {...(paramsWithValues as any)} />);
const svg = document.querySelector('svg');
const textElements = svg?.querySelectorAll('text');
expect(svg).toBeInTheDocument();
expect(textElements).toBeDefined();
expect(textElements!.length).toBeGreaterThan(0);
});
test('should handle empty data gracefully', () => {
const paramsWithEmptyData = { ...createMockParams };
paramsWithEmptyData.data = [];
renderWithTheme(<BarChartRenderer {...(paramsWithEmptyData as any)} />);
const container = screen.getByLabelText('Bar chart for Test Column');
expect(container).toBeInTheDocument();
});

View File

@@ -351,6 +351,7 @@ export default function transformProps(chartProps: EchartsGanttChartProps) {
[GenericDataType.String]: undefined,
[GenericDataType.Temporal]: tooltipTimeFormatter,
[GenericDataType.Boolean]: undefined,
[GenericDataType.Chart]: undefined,
};
const echartOptions: EChartsCoreOption = {

View File

@@ -0,0 +1,149 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { t } from '@superset-ui/core';
import { styled } from '@apache-superset/core/ui';
import { Popover, Input, Button, InputRef } from '@superset-ui/core/components';
import { ChartColumnPopoverProps, ChartColumnConfig } from './types';
const PopoverContent = styled.div`
width: 300px;
padding: ${({ theme }) => theme.sizeUnit * 2}px;
`;
const FormItem = styled.div`
margin-bottom: ${({ theme }) => theme.sizeUnit * 3}px;
`;
const Label = styled.div`
margin-bottom: ${({ theme }) => theme.sizeUnit}px;
font-weight: ${({ theme }) => theme.fontWeightStrong};
font-size: ${({ theme }) => theme.fontSizeSM}px;
`;
const ButtonContainer = styled.div`
display: flex;
justify-content: space-between;
gap: ${({ theme }) => theme.sizeUnit * 2}px;
margin-top: ${({ theme }) => theme.sizeUnit * 6}px;
`;
const StyledButton = styled(Button)`
flex: 1;
`;
export const ChartColumnPopover = ({
onChange,
config,
title,
children,
...popoverProps
}: ChartColumnPopoverProps) => {
const [isOpen, setIsOpen] = useState(false);
const [label, setLabel] = useState(config?.label || '');
const inputRef = useRef<InputRef>(null);
const handleSave = () => {
const newConfig: ChartColumnConfig = {
key: config?.key || `chart_${Date.now()}`,
label: label || t('Chart Column'),
};
onChange(newConfig);
setIsOpen(false);
// reset for next add
if (!config) {
setLabel('');
}
};
const handleCancel = useCallback(() => {
setIsOpen(false);
// reset to original values if editing
if (config) {
setLabel(config.label);
} else {
setLabel('');
}
}, [config]);
// focus input when popover opens
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus({ cursor: 'end' });
}
}, [isOpen]);
// close popover on ESC key press
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpen) {
handleCancel();
}
};
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, handleCancel]);
const content = (
<PopoverContent>
<FormItem>
<Label>{t('Column Label')}</Label>
<Input
ref={inputRef}
value={label}
onChange={e => setLabel(e.target.value)}
placeholder={t('Enter column label')}
/>
</FormItem>
<ButtonContainer>
<StyledButton buttonStyle="secondary" onClick={handleCancel}>
{t('Close')}
</StyledButton>
<StyledButton
buttonStyle="primary"
onClick={handleSave}
disabled={!label.trim()}
>
{t('Save')}
</StyledButton>
</ButtonContainer>
</PopoverContent>
);
return (
<Popover
content={content}
title={title}
trigger="click"
open={isOpen}
onOpenChange={setIsOpen}
placement="right"
{...popoverProps}
>
{children}
</Popover>
);
};

View File

@@ -0,0 +1,150 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback, useState } from 'react';
import { t } from '@superset-ui/core';
import { Icons } from '@superset-ui/core/components/Icons';
import ControlHeader from 'src/explore/components/ControlHeader';
import {
AddControlLabel,
HeaderContainer,
LabelsContainer,
OptionControlLabel,
DragContainer,
} from '../OptionControls';
import { ChartColumnPopover } from './ChartColumnPopover';
import { ChartColumnsControlProps, ChartColumnConfig } from './types';
const CHART_COLUMN_DND_TYPE = 'ChartColumn';
const ChartColumnsControl = ({
value,
onChange,
...props
}: ChartColumnsControlProps) => {
const [chartColumns, setChartColumns] = useState<ChartColumnConfig[]>(
value ?? [],
);
const onDelete = useCallback(
(index: number) => {
setChartColumns(prevColumns => {
const newColumns = prevColumns.filter((_, i) => i !== index);
if (onChange) {
onChange(newColumns);
}
return newColumns;
});
},
[onChange],
);
const onAdd = useCallback(
(config: ChartColumnConfig) => {
setChartColumns(prevColumns => {
const newColumns = [...prevColumns, config];
if (onChange) {
onChange(newColumns);
}
return newColumns;
});
},
[onChange],
);
const onEdit = useCallback(
(newConfig: ChartColumnConfig, index: number) => {
setChartColumns(prevColumns => {
const newColumns = [...prevColumns];
newColumns.splice(index, 1, newConfig);
if (onChange) {
onChange(newColumns);
}
return newColumns;
});
},
[onChange],
);
const moveLabel = useCallback((dragIndex: number, hoverIndex: number) => {
setChartColumns(prevColumns => {
const newColumns = [...prevColumns];
[newColumns[hoverIndex], newColumns[dragIndex]] = [
newColumns[dragIndex],
newColumns[hoverIndex],
];
return newColumns;
});
}, []);
const onDropLabel = useCallback(() => {
if (onChange) {
onChange(chartColumns);
}
}, [chartColumns, onChange]);
return (
<div>
<HeaderContainer>
<ControlHeader {...props} />
</HeaderContainer>
<LabelsContainer>
{chartColumns.map((chartColumn, index) => (
<DragContainer key={chartColumn.key}>
<ChartColumnPopover
title={t('Edit chart column')}
config={chartColumn}
onChange={config => onEdit(config, index)}
destroyOnHidden
>
<OptionControlLabel
label={chartColumn.label}
onRemove={() => onDelete(index)}
onMoveLabel={moveLabel}
onDropLabel={onDropLabel}
index={index}
type={CHART_COLUMN_DND_TYPE}
withCaret
multi
/>
</ChartColumnPopover>
</DragContainer>
))}
<ChartColumnPopover
title={t('Add new chart column')}
onChange={onAdd}
destroyOnHidden
>
<AddControlLabel>
<Icons.PlusOutlined
iconSize="m"
css={theme => ({
margin: `auto ${theme.sizeUnit}px auto 0`,
verticalAlign: 'baseline',
})}
/>
{t('Add new chart column')}
</AddControlLabel>
</ChartColumnPopover>
</LabelsContainer>
</div>
);
};
export default ChartColumnsControl;

View File

@@ -0,0 +1,41 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ReactNode } from 'react';
import { PopoverProps } from '@superset-ui/core/components/Popover';
import { ControlComponentProps } from '@superset-ui/chart-controls';
export type ChartColumnConfig = {
key: string;
label: string;
};
export type ChartColumnsControlProps = ControlComponentProps<
ChartColumnConfig[]
> & {
label: string;
description: string;
};
export type ChartColumnPopoverProps = PopoverProps & {
onChange: (value: ChartColumnConfig) => void;
config?: ChartColumnConfig;
title: string;
children: ReactNode;
};

View File

@@ -21,6 +21,7 @@ import { Input, InputNumber, Select } from '@superset-ui/core/components';
import Slider from '@superset-ui/core/components/Slider';
import CurrencyControl from '../../CurrencyControl';
import CheckboxControl from '../../CheckboxControl';
import ColorPickerControl from '../../ColorPickerControl';
export const ControlFormItemComponents = {
Slider,
@@ -32,4 +33,5 @@ export const ControlFormItemComponents = {
Checkbox: CheckboxControl,
RadioButtonControl: sharedControlComponents.RadioButtonControl,
CurrencyControl,
ColorPickerControl,
};

View File

@@ -42,7 +42,14 @@ export type SharedColumnConfigProp =
| 'visible'
| 'customColumnName'
| 'displayTypeIcon'
| 'currencyFormat';
| 'chartType'
| 'currencyFormat'
| 'width'
| 'height'
| 'color'
| 'strokeWidth'
| 'showValues'
| 'showPoints';
const d3NumberFormat: ControlFormItemSpec<'Select'> = {
allowNewOptions: true,
@@ -179,6 +186,68 @@ const visible: ControlFormItemSpec<'Checkbox'> = {
defaultValue: true,
debounceDelay: 200,
};
const chartType: ControlFormItemSpec<'Select'> = {
controlType: 'Select',
label: t('Chart Type'),
description: t('Customize the chart type used to visualize row data.'),
options: [
{ value: 'sparkline', label: t('Sparkline') },
{ value: 'minibar', label: t('Mini Bar') },
],
defaultValue: 'sparkline',
debounceDelay: 200,
};
const width: ControlFormItemSpec<'InputNumber'> = {
controlType: 'InputNumber',
label: t('Width'),
description: t(
'Width of the chart. You may also need to adjust column width if the chart width is too large.',
),
defaultValue: 100, // default width from transformProps.ts
debounceDelay: 200,
};
const height: ControlFormItemSpec<'InputNumber'> = {
controlType: 'InputNumber',
label: t('Height'),
description: t('Height of the chart'),
defaultValue: 60, // default height from transformProps.ts
debounceDelay: 200,
};
const color: ControlFormItemSpec<'ColorPickerControl'> = {
controlType: 'ColorPickerControl',
label: t('Color'),
defaultValue: { r: 0, g: 255, b: 0, a: 1 },
description: t('Color of the chart'),
debounceDelay: 200,
};
const strokeWidth: ControlFormItemSpec<'InputNumber'> = {
controlType: 'InputNumber',
label: t('Stroke Width'),
description: t('Stroke width of the chart (sparkline only)'),
defaultValue: 1.5, // default stroke width from transformProps.ts
debounceDelay: 200,
};
const showValues: ControlFormItemSpec<'Checkbox'> = {
controlType: 'Checkbox',
label: t('Show Values'),
description: t('Whether to show values in the chart'),
defaultValue: true,
debounceDelay: 200,
};
const showPoints: ControlFormItemSpec<'Checkbox'> = {
controlType: 'Checkbox',
label: t('Show Points'),
description: t('Whether to show points in the chart'),
defaultValue: true,
debounceDelay: 200,
};
/**
* All configurable column formatting properties.
*/
@@ -204,6 +273,13 @@ export const SHARED_COLUMN_CONFIG_PROPS = {
colorPositiveNegative,
currencyFormat,
visible,
chartType,
width,
height,
color,
strokeWidth,
showValues,
showPoints,
};
export const DEFAULT_CONFIG_FORM_LAYOUT: ColumnConfigFormLayout = {
@@ -249,4 +325,29 @@ export const DEFAULT_CONFIG_FORM_LAYOUT: ColumnConfigFormLayout = {
{ name: 'horizontalAlign', override: { defaultValue: 'left' } },
],
],
[GenericDataType.Chart]: [
{
tab: t('Column Settings'),
children: [
[
'columnWidth',
{ name: 'horizontalAlign', override: { defaultValue: 'left' } },
],
['showCellBars'],
['alignPositiveNegative'],
['colorPositiveNegative'],
],
},
{
tab: t('Chart Settings'),
children: [
['chartType'],
['width', 'height'],
['color'],
['strokeWidth'],
['showValues'],
['showPoints'],
],
},
],
};

View File

@@ -59,6 +59,7 @@ import NumberControl from './NumberControl';
import TimeRangeControl from './TimeRangeControl';
import ColorBreakpointsControl from './ColorBreakpointsControl';
import MatrixifyDimensionControl from './MatrixifyDimensionControl';
import ChartColumnsControl from './ChartColumnsControl';
const extensionsRegistry = getExtensionsRegistry();
const DateFilterControlExtension = extensionsRegistry.get(
@@ -70,6 +71,7 @@ const controlMap = {
AnnotationLayerControl,
BoundsControl,
CheckboxControl,
ChartColumnsControl,
CollectionControl,
ColorPickerControl,
ColorSchemeControl,

View File

@@ -143,3 +143,28 @@ test('should return empty div when all data is null', () => {
expect(container).toBeInTheDocument();
expect(container.querySelector('svg')).toBeNull();
});
test('should not render glyph points when showPoints is false', () => {
render(<SparklineCell {...defaultProps} showPoints={false} />);
const svg = document.querySelector('svg');
expect(svg).toBeInTheDocument();
const circles = svg!.querySelectorAll('circle');
expect(circles.length).toBe(0);
});
test('should apply custom color and strokeWidth to the series', () => {
render(
<SparklineCell
{...defaultProps}
color="#FF0000"
strokeWidth={4}
showPoints={true}
/>,
);
const line = document.querySelector('[stroke="#FF0000"]');
expect(line).toBeInTheDocument();
expect(line).toHaveAttribute('stroke-width', '4');
});

View File

@@ -58,6 +58,9 @@ interface SparklineCellProps {
width?: number;
yAxisBounds?: [number | undefined, number | undefined];
sparkType?: SparkType;
color?: string;
strokeWidth?: number;
showPoints?: boolean;
}
const MARGIN = {
@@ -79,19 +82,23 @@ const SparklineCell = ({
showYAxis = false,
entries = [],
sparkType = 'line',
color,
strokeWidth = 1,
showPoints = true,
}: SparklineCellProps): ReactElement => {
const theme = useTheme();
const finalSeriesColor = color || theme.colorText;
const xyTheme = useMemo(
() =>
buildChartTheme({
backgroundColor: `${theme.colorBgContainer}`,
colors: [`${theme.colorText}`],
colors: [`${finalSeriesColor}`],
gridColor: `${theme.colorSplit}`,
gridColorDark: `${theme.colorBorder}`,
tickLength: 6,
}),
[theme],
[theme, finalSeriesColor],
);
const validData = useMemo(
@@ -158,7 +165,7 @@ const SparklineCell = ({
yScale={{
...yScaleConfig,
}}
xScale={{ type: 'band', paddingInner: 0.5 }}
xScale={{ type: 'band', paddingInner: 0.5, paddingOuter: 0.1 }}
theme={xyTheme}
>
{showYAxis && (
@@ -189,61 +196,66 @@ const SparklineCell = ({
dataKey={dataKey}
xAccessor={xAccessor}
yAccessor={yAccessor}
{...(sparkType === 'line' || sparkType === 'area'
? { strokeWidth: strokeWidth }
: {})}
/>
<Tooltip
glyphStyle={{ strokeWidth: 1 }}
showDatumGlyph
showVerticalCrosshair
snapTooltipToDatumX
snapTooltipToDatumY
verticalCrosshairStyle={{
stroke: theme.colorText,
strokeDasharray: '3 3',
strokeWidth: 1,
}}
renderTooltip={({ tooltipData }) => {
const idx = tooltipData?.datumByKey[dataKey]?.index;
{showPoints && (
<Tooltip
glyphStyle={{ strokeWidth: 1 }}
showDatumGlyph
showVerticalCrosshair
snapTooltipToDatumX
snapTooltipToDatumY
verticalCrosshairStyle={{
stroke: theme.colorText,
strokeDasharray: '3 3',
strokeWidth: 1,
}}
renderTooltip={({ tooltipData }) => {
const idx = tooltipData?.datumByKey[dataKey]?.index;
if (idx === undefined || !entries[idx]) {
return null;
}
if (idx === undefined || !entries[idx]) {
return null;
}
const value = data[idx] ?? 0;
const timeValue = entries[idx]?.time;
const value = data[idx] ?? 0;
const timeValue = entries[idx]?.time;
return (
<div
css={() => ({
color: theme.colorText,
padding: '8px',
})}
>
<strong
return (
<div
css={() => ({
color: theme.colorText,
display: 'block',
marginBottom: '4px',
padding: '8px',
})}
>
{formatNumber(numberFormat, value)}
</strong>
{timeValue && (
<div
<strong
css={() => ({
color: theme.colorTextSecondary,
fontSize: '12px',
color: theme.colorText,
display: 'block',
marginBottom: '4px',
})}
>
{formatTime(
dateFormat,
extendedDayjs.utc(timeValue).toDate(),
)}
</div>
)}
</div>
);
}}
/>
{formatNumber(numberFormat, value)}
</strong>
{timeValue && (
<div
css={() => ({
color: theme.colorTextSecondary,
fontSize: '12px',
})}
>
{formatTime(
dateFormat,
extendedDayjs.utc(timeValue).toDate(),
)}
</div>
)}
</div>
);
}}
/>
)}
</XYChart>
<style>
{`

View File

@@ -642,7 +642,7 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = {
# chart builder
"DATASET_FOLDERS": False,
# Enable Table V2 Viz plugin
"AG_GRID_TABLE_ENABLED": False,
"AG_GRID_TABLE_ENABLED": True,
# Enable Table v2 time comparison feature
"TABLE_V2_TIME_COMPARISON_ENABLED": False,
# Enable Superset extensions, which allow users to add custom functionality