feat: Improve pie chart implementation (#1773)

Closes HDX-3479

# Summary

This PR makes a number of improvements to new pie chart implementation (#1704)

1. Pie charts are now limited to 1 series. Previously, the pie chart summed the values of each series by group, and used the sum as the slice value. This is non-obvious and probably not what users expect. With a one-series limit, this problem is eliminated. Further, the logic for formatting the pie chart data from the clickhouse response is dramatically simpler.
2. Slices are now ordered by value decreasing, to avoid randomly changing slice order on refresh
3. Instead of being randomly generated, slice colors are now consistent with the theme colors and auto-detect log and trace severity levels, matching line/bar chart behavior
4. The external dashboards API now supports reading and writing pie charts. The transformation code has been updated so that there will be a type error if any new chart types are added in the future without updating the external API code.
5. The pie chart's tooltip now matches the style of the line chart tooltip, and is updated appropriately based on the app theme and light/dark mode.
6. The chart's number format is now applied to values in the pie chart tooltip
7. Slice labels are now correctly populated when a map is accessed in the Group By (eg. when grouping by `ResourceAttributes['app']`, the slice labels include the `app` value instead of being empty).
8. Also, added some unit tests for the pie chart data transformation, and moved it to ChartUtils with the other similar chart data transformation code.
This commit is contained in:
Drew Davis 2026-02-23 11:04:26 -05:00 committed by GitHub
parent e55b81bce7
commit b5bb69e37c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 592 additions and 243 deletions

View file

@ -0,0 +1,6 @@
---
"@hyperdx/api": patch
"@hyperdx/app": patch
---
fix: Improve Pie Chart implemententation

View file

@ -1178,6 +1178,47 @@
}
}
},
"PieChartConfig": {
"type": "object",
"required": [
"displayType",
"sourceId",
"select"
],
"description": "Configuration for a pie chart tile. Each slice represents one group value.",
"properties": {
"displayType": {
"type": "string",
"enum": [
"pie"
],
"example": "pie"
},
"sourceId": {
"type": "string",
"description": "ID of the data source to query.",
"example": "65f5e4a3b9e77c001a111111"
},
"select": {
"type": "array",
"minItems": 1,
"maxItems": 1,
"description": "Exactly one aggregated value used to size each pie slice.",
"items": {
"$ref": "#/components/schemas/SelectItem"
}
},
"groupBy": {
"type": "string",
"maxLength": 10000,
"description": "Field expression to group results by (one slice per group value).",
"example": "service"
},
"numberFormat": {
"$ref": "#/components/schemas/NumberFormat"
}
}
},
"SearchChartConfig": {
"type": "object",
"required": [
@ -1255,6 +1296,9 @@
{
"$ref": "#/components/schemas/NumberChartConfig"
},
{
"$ref": "#/components/schemas/PieChartConfig"
},
{
"$ref": "#/components/schemas/SearchChartConfig"
},
@ -1269,6 +1313,7 @@
"stacked_bar": "#/components/schemas/BarChartConfig",
"table": "#/components/schemas/TableChartConfig",
"number": "#/components/schemas/NumberChartConfig",
"pie": "#/components/schemas/PieChartConfig",
"search": "#/components/schemas/SearchChartConfig",
"markdown": "#/components/schemas/MarkdownChartConfig"
}

View file

@ -1648,6 +1648,26 @@ describe('External API v2 Dashboards - new format', () => {
},
});
const createPieChart = (sourceId: string): ExternalDashboardTileWithId => ({
name: 'Pie Chart',
x: 6,
y: 3,
w: 3,
h: 3,
id: new ObjectId().toString(),
config: {
displayType: 'pie',
sourceId,
select: [
{
aggFn: 'count',
where: '',
},
],
groupBy: 'service.name',
},
});
const server = getServer();
let agent, team, user, traceSource, metricSource;
@ -1912,6 +1932,7 @@ describe('External API v2 Dashboards - new format', () => {
createTableChart(traceSource._id.toString()),
createNumberChart(traceSource._id.toString()),
createMarkdownChart(),
createPieChart(traceSource._id.toString()),
],
tags: ['test', 'chart-types'],
};
@ -1922,18 +1943,45 @@ describe('External API v2 Dashboards - new format', () => {
const { id } = response.body.data;
expect(response.body.data).toHaveProperty('id');
expect(response.body.data.tiles.length).toBe(4);
expect(response.body.data.tiles.length).toBe(5);
// Verify by retrieving the dashboard
const retrieveResponse = await authRequest('get', `${BASE_URL}/${id}`);
expect(retrieveResponse.status).toBe(200);
expect(retrieveResponse.body.data.tiles.length).toBe(4);
expect(retrieveResponse.body.data.tiles.length).toBe(5);
expect(retrieveResponse.body.data.tags).toEqual(['test', 'chart-types']);
});
it('can round-trip all supported chart types and all supported fields on each chart type', async () => {
// Arrange
const pieChart: ExternalDashboardTile = {
name: 'Pie Chart',
x: 6,
y: 3,
w: 6,
h: 3,
config: {
displayType: 'pie',
sourceId: traceSource._id.toString(),
select: [
{
aggFn: 'quantile',
level: 0.5,
valueExpression: 'Duration',
alias: 'Median Duration',
where: "env = 'production'",
whereLanguage: 'sql',
},
],
groupBy: 'service.name',
numberFormat: {
output: 'number',
mantissa: 2,
},
},
};
const lineChart: ExternalDashboardTile = {
name: 'Line Chart',
x: 0,
@ -2087,7 +2135,14 @@ describe('External API v2 Dashboards - new format', () => {
const response = await authRequest('post', BASE_URL)
.send({
name: 'Dashboard with All Chart Types',
tiles: [lineChart, barChart, tableChart, numberChart, markdownChart],
tiles: [
lineChart,
barChart,
tableChart,
numberChart,
markdownChart,
pieChart,
],
tags: ['round-trip-test'],
})
.expect(200);
@ -2098,6 +2153,7 @@ describe('External API v2 Dashboards - new format', () => {
expect(omit(response.body.data.tiles[2], ['id'])).toEqual(tableChart);
expect(omit(response.body.data.tiles[3], ['id'])).toEqual(numberChart);
expect(omit(response.body.data.tiles[4], ['id'])).toEqual(markdownChart);
expect(omit(response.body.data.tiles[5], ['id'])).toEqual(pieChart);
});
it('should return 400 when source IDs do not exist', async () => {

View file

@ -585,6 +585,37 @@ async function getMissingSources(
* numberFormat:
* $ref: '#/components/schemas/NumberFormat'
*
* PieChartConfig:
* type: object
* required:
* - displayType
* - sourceId
* - select
* description: Configuration for a pie chart tile. Each slice represents one group value.
* properties:
* displayType:
* type: string
* enum: [pie]
* example: "pie"
* sourceId:
* type: string
* description: ID of the data source to query.
* example: "65f5e4a3b9e77c001a111111"
* select:
* type: array
* minItems: 1
* maxItems: 1
* description: Exactly one aggregated value used to size each pie slice.
* items:
* $ref: '#/components/schemas/SelectItem'
* groupBy:
* type: string
* maxLength: 10000
* description: Field expression to group results by (one slice per group value).
* example: "service"
* numberFormat:
* $ref: '#/components/schemas/NumberFormat'
*
* SearchChartConfig:
* type: object
* required:
@ -641,6 +672,7 @@ async function getMissingSources(
* - $ref: '#/components/schemas/BarChartConfig'
* - $ref: '#/components/schemas/TableChartConfig'
* - $ref: '#/components/schemas/NumberChartConfig'
* - $ref: '#/components/schemas/PieChartConfig'
* - $ref: '#/components/schemas/SearchChartConfig'
* - $ref: '#/components/schemas/MarkdownChartConfig'
* discriminator:
@ -650,6 +682,7 @@ async function getMissingSources(
* stacked_bar: '#/components/schemas/BarChartConfig'
* table: '#/components/schemas/TableChartConfig'
* number: '#/components/schemas/NumberChartConfig'
* pie: '#/components/schemas/PieChartConfig'
* search: '#/components/schemas/SearchChartConfig'
* markdown: '#/components/schemas/MarkdownChartConfig'
*

View file

@ -91,8 +91,8 @@ const convertToExternalTileChartConfig = (
};
switch (config.displayType) {
case 'line':
case 'stacked_bar':
case DisplayType.Line:
case DisplayType.StackedBar:
return {
displayType: config.displayType,
sourceId,
@ -106,12 +106,12 @@ const convertToExternalTileChartConfig = (
select: Array.isArray(config.select)
? config.select.map(convertToExternalSelectItem)
: [DEFAULT_SELECT_ITEM],
...(config.displayType === 'line'
...(config.displayType === DisplayType.Line
? { compareToPreviousPeriod: config.compareToPreviousPeriod }
: {}),
numberFormat: config.numberFormat,
};
case 'number':
case DisplayType.Number:
return {
displayType: config.displayType,
sourceId,
@ -120,7 +120,17 @@ const convertToExternalTileChartConfig = (
: [DEFAULT_SELECT_ITEM],
numberFormat: config.numberFormat,
};
case 'table':
case DisplayType.Pie:
return {
displayType: config.displayType,
sourceId,
select: Array.isArray(config.select)
? [convertToExternalSelectItem(config.select[0])]
: [DEFAULT_SELECT_ITEM],
groupBy: stringValueOrDefault(config.groupBy, undefined),
numberFormat: config.numberFormat,
};
case DisplayType.Table:
return {
...pick(config, ['having', 'numberFormat']),
displayType: config.displayType,
@ -135,7 +145,7 @@ const convertToExternalTileChartConfig = (
: [DEFAULT_SELECT_ITEM],
orderBy: stringValueOrDefault(config.orderBy, undefined),
};
case 'search':
case DisplayType.Search:
return {
displayType: config.displayType,
sourceId,
@ -143,17 +153,20 @@ const convertToExternalTileChartConfig = (
where: config.where,
whereLanguage: config.whereLanguage ?? 'lucene',
};
case 'markdown':
case DisplayType.Markdown:
return {
displayType: config.displayType,
markdown: stringValueOrDefault(config.markdown, ''),
};
default:
case DisplayType.Heatmap:
case undefined:
logger.error(
{ config },
'Error converting chart config to external chart - unrecognized display type',
'Error converting chart config to external chart - unsupported display type',
);
return undefined;
default:
config.displayType satisfies never;
}
};
@ -257,6 +270,16 @@ export function convertToInternalTileConfig(
name,
};
break;
case 'pie':
internalConfig = {
...pick(externalConfig, ['groupBy', 'numberFormat']),
displayType: DisplayType.Pie,
select: [convertToInternalSelectItem(externalConfig.select[0])],
source: externalConfig.sourceId,
where: '',
name,
};
break;
case 'search':
internalConfig = {
...pick(externalConfig, ['select', 'where']),
@ -276,6 +299,13 @@ export function convertToInternalTileConfig(
name,
};
break;
default:
// Typecheck to ensure all display types are handled
externalConfig satisfies never;
// We should never hit this due to the typecheck above.
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
internalConfig = {} as SavedChartConfig;
}
// Omit keys that are null/undefined, so that they're not saved as null in Mongo.

View file

@ -234,6 +234,14 @@ const externalDashboardNumberChartConfigSchema = z.object({
numberFormat: NumberFormatSchema.optional(),
});
const externalDashboardPieChartConfigSchema = z.object({
displayType: z.literal('pie'),
sourceId: objectIdSchema,
select: z.array(externalDashboardSelectItemSchema).length(1),
groupBy: z.string().max(10000).optional(),
numberFormat: NumberFormatSchema.optional(),
});
const externalDashboardSearchChartConfigSchema = z.object({
displayType: z.literal('search'),
sourceId: objectIdSchema,
@ -253,6 +261,7 @@ export const externalDashboardTileConfigSchema = z
externalDashboardBarChartConfigSchema,
externalDashboardTableChartConfigSchema,
externalDashboardNumberChartConfigSchema,
externalDashboardPieChartConfigSchema,
externalDashboardMarkdownChartConfigSchema,
externalDashboardSearchChartConfigSchema,
])

View file

@ -503,6 +503,40 @@ function inferGroupColumns(meta: Array<{ name: string; type: string }>) {
]);
}
export function formatResponseForPieChart(
data: ResponseJSON<Record<string, unknown>>,
getColor: (index: number, label: string) => string,
): Array<{ label: string; value: number; color: string }> {
if (!data.meta || data.data.length === 0) return [];
const valueColumns = inferValueColumns(data.meta, new Set());
const groupByColumns = inferGroupColumns(data.meta);
if (!valueColumns?.length) return [];
const valueColumn = valueColumns[0].name;
return (
data.data
.map(row => {
const label = groupByColumns?.length
? groupByColumns.map(({ name }) => row[name]).join(' - ')
: valueColumn;
const rawValue = row[valueColumn];
const value =
typeof rawValue === 'number'
? rawValue
: Number.parseFloat(`${rawValue}`);
return { label, value };
})
.filter(entry => !isNaN(entry.value) && isFinite(entry.value))
// Sort in descending order so the largest slice is always first and gets the first color in the palette
.sort((a, b) => b.value - a.value)
.map((entry, index) => ({
...entry,
color: getColor(index, entry.label),
}))
);
}
export function getPreviousDateRange(currentRange: [Date, Date]): [Date, Date] {
const [start, end] = currentRange;
const offsetSeconds = differenceInSeconds(end, start);

View file

@ -20,12 +20,14 @@ import {
import { AxisDomain } from 'recharts/types/util/types';
import { DisplayType } from '@hyperdx/common-utils/dist/types';
import { Popover } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconCaretDownFilled, IconCaretUpFilled } from '@tabler/icons-react';
import type { NumberFormat } from '@/types';
import { COLORS, formatNumber, truncateMiddle } from '@/utils';
import {
ChartTooltipContainer,
ChartTooltipItem,
} from './components/charts/ChartTooltip';
import {
convertGranularityToSeconds,
LineData,
@ -48,40 +50,6 @@ type TooltipPayload = {
opacity?: number;
};
const percentFormatter = new Intl.NumberFormat('en-US', {
style: 'percent',
maximumFractionDigits: 2,
});
const calculatePercentChange = (current: number, previous: number) => {
if (previous === 0) {
return current === 0 ? 0 : undefined;
}
return (current - previous) / previous;
};
const PercentChange = ({
current,
previous,
}: {
current: number;
previous: number;
}) => {
const percentChange = calculatePercentChange(current, previous);
if (percentChange == undefined) {
return null;
}
const Icon = percentChange > 0 ? IconCaretUpFilled : IconCaretDownFilled;
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 0 }}>
(<Icon size={12} />
{percentFormatter.format(Math.abs(percentChange))})
</span>
);
};
export const TooltipItem = memo(
({
p,
@ -93,30 +61,16 @@ export const TooltipItem = memo(
numberFormat?: NumberFormat;
}) => {
return (
<div className="d-flex gap-2 items-center justify-center">
<div>
<svg width="12" height="4">
<line
x1="0"
y1="2"
x2="12"
y2="2"
stroke={p.color}
opacity={p.opacity}
strokeDasharray={p.strokeDasharray}
/>
</svg>
</div>
<div>
<span style={{ color: p.color }}>
{truncateMiddle(p.name ?? p.dataKey, 50)}
</span>
: {numberFormat ? formatNumber(p.value, numberFormat) : p.value}{' '}
{previous && (
<PercentChange current={p.value} previous={previous?.value} />
)}
</div>
</div>
<ChartTooltipItem
color={p.color ?? ''}
name={p.name ?? p.dataKey}
value={p.value}
numberFormat={numberFormat}
indicator="line"
strokeDasharray={p.strokeDasharray}
opacity={p.opacity}
previous={previous?.value}
/>
);
},
);
@ -145,42 +99,42 @@ const HDXLineChartTooltip = withErrorBoundary(
);
if (active && payload && payload.length) {
const header = (
<>
<FormatTime value={label * 1000} />
{previousPeriodOffsetSeconds != null && (
<>
{' (vs '}
<FormatTime
value={(label - previousPeriodOffsetSeconds) * 1000}
/>
{')'}
</>
)}
</>
);
return (
<div className={styles.chartTooltip}>
<div className={styles.chartTooltipHeader}>
<FormatTime value={label * 1000} />
{previousPeriodOffsetSeconds != null && (
<>
{' (vs '}
<FormatTime
value={(label - previousPeriodOffsetSeconds) * 1000}
/>
{')'}
</>
)}
</div>
<div className={styles.chartTooltipContent}>
{payload
.sort((a: TooltipPayload, b: TooltipPayload) => b.value - a.value)
.map((p: TooltipPayload) => {
const previousKey = lineDataMap[p.dataKey]?.previousPeriodKey;
const isPreviousPeriod = previousKey === p.dataKey;
const previousPayload =
!isPreviousPeriod && previousKey
? payloadByKey.get(previousKey)
: undefined;
<ChartTooltipContainer header={header}>
{payload
.sort((a: TooltipPayload, b: TooltipPayload) => b.value - a.value)
.map((p: TooltipPayload) => {
const previousKey = lineDataMap[p.dataKey]?.previousPeriodKey;
const isPreviousPeriod = previousKey === p.dataKey;
const previousPayload =
!isPreviousPeriod && previousKey
? payloadByKey.get(previousKey)
: undefined;
return (
<TooltipItem
key={p.dataKey}
p={p}
numberFormat={numberFormat}
previous={previousPayload}
/>
);
})}
</div>
</div>
return (
<TooltipItem
key={p.dataKey}
p={p}
numberFormat={numberFormat}
previous={previousPayload}
/>
);
})}
</ChartTooltipContainer>
);
}
return null;
@ -195,38 +149,6 @@ const HDXLineChartTooltip = withErrorBoundary(
},
);
function CopyableLegendItem({ entry }: any) {
return (
<span
className={styles.legendItem}
style={{ color: entry.color }}
role="button"
onClick={() => {
window.navigator.clipboard.writeText(entry.value);
notifications.show({ color: 'green', message: `Copied to clipboard` });
}}
title="Click to expand"
>
<div className="d-flex gap-1 items-center justify-center">
<div>
<svg width="12" height="4">
<line
x1="0"
y1="2"
x2="12"
y2="2"
stroke={entry.color}
opacity={entry.opacity}
strokeDasharray={entry.payload?.strokeDasharray}
/>
</svg>
</div>
{entry.value}
</div>
</span>
);
}
function ExpandableLegendItem({
entry,
expanded,

View file

@ -8,6 +8,7 @@ import {
convertToNumberChartConfig,
convertToTableChartConfig,
convertToTimeChartConfig,
formatResponseForPieChart,
formatResponseForTimeChart,
} from '@/ChartUtils';
import { COLORS, getChartColorError } from '@/utils';
@ -738,4 +739,153 @@ describe('ChartUtils', () => {
expect(convertedConfig.limit).toEqual({ limit: 200 });
});
});
describe('formatResponseForPieChart', () => {
const getColor = (index: number, label: string) =>
`color-${index}-${label}`;
it('returns empty array when data.data is empty', () => {
const result = formatResponseForPieChart(
{
data: [],
meta: [{ name: 'count()', type: 'UInt64' }],
},
getColor,
);
expect(result).toEqual([]);
});
it('returns empty array when there are no numeric value columns', () => {
const result = formatResponseForPieChart(
{
data: [{ ServiceName: 'checkout' }],
meta: [{ name: 'ServiceName', type: 'LowCardinality(String)' }],
},
getColor,
);
expect(result).toEqual([]);
});
it('uses the value column name as label when there are no group-by columns', () => {
const result = formatResponseForPieChart(
{
data: [{ 'count()': 10 }],
meta: [{ name: 'count()', type: 'UInt64' }],
},
getColor,
);
expect(result).toEqual([
{ label: 'count()', value: 10, color: 'color-0-count()' },
]);
});
it('joins group-by column values with " - " as the label', () => {
const result = formatResponseForPieChart(
{
data: [
{ 'count()': 10, ServiceName: 'checkout', env: 'prod' },
{ 'count()': 5, ServiceName: 'shipping', env: 'prod' },
],
meta: [
{ name: 'count()', type: 'UInt64' },
{ name: 'ServiceName', type: 'LowCardinality(String)' },
{ name: 'env', type: 'LowCardinality(String)' },
],
},
getColor,
);
expect(result).toEqual([
{
label: 'checkout - prod',
value: 10,
color: 'color-0-checkout - prod',
},
{
label: 'shipping - prod',
value: 5,
color: 'color-1-shipping - prod',
},
]);
});
it('parses string numeric values', () => {
const result = formatResponseForPieChart(
{
data: [{ 'count()': '42' }],
meta: [{ name: 'count()', type: 'UInt64' }],
},
getColor,
);
expect(result).toEqual([
{ label: 'count()', value: 42, color: 'color-0-count()' },
]);
});
it('filters out NaN values', () => {
const result = formatResponseForPieChart(
{
data: [{ 'count()': 'not-a-number' }, { 'count()': 5 }],
meta: [{ name: 'count()', type: 'UInt64' }],
},
getColor,
);
expect(result).toEqual([
{ label: 'count()', value: 5, color: 'color-0-count()' },
]);
});
it('sorts entries in descending order by value', () => {
const result = formatResponseForPieChart(
{
data: [
{ 'count()': 3, ServiceName: 'c' },
{ 'count()': 10, ServiceName: 'a' },
{ 'count()': 1, ServiceName: 'b' },
],
meta: [
{ name: 'count()', type: 'UInt64' },
{ name: 'ServiceName', type: 'LowCardinality(String)' },
],
},
getColor,
);
expect(result.map(e => e.value)).toEqual([10, 3, 1]);
});
it('assigns colors by sorted index', () => {
const result = formatResponseForPieChart(
{
data: [
{ 'count()': 1, ServiceName: 'b' },
{ 'count()': 10, ServiceName: 'a' },
],
meta: [
{ name: 'count()', type: 'UInt64' },
{ name: 'ServiceName', type: 'LowCardinality(String)' },
],
},
getColor,
);
// 'a' (value 10) sorts first and gets index 0; 'b' (value 1) gets index 1
expect(result[0]).toMatchObject({ label: 'a', color: 'color-0-a' });
expect(result[1]).toMatchObject({ label: 'b', color: 'color-1-b' });
});
it('uses only the first numeric column as the value column', () => {
const result = formatResponseForPieChart(
{
data: [{ count: 5, duration: 999, ServiceName: 'svc' }],
meta: [
{ name: 'count', type: 'UInt64' },
{ name: 'duration', type: 'Float64' },
{ name: 'ServiceName', type: 'LowCardinality(String)' },
],
},
getColor,
);
expect(result).toEqual([
{ label: 'svc', value: 5, color: 'color-0-svc' },
]);
});
});
});

View file

@ -69,6 +69,7 @@ import {
AGG_FNS,
buildTableRowSearchUrl,
convertToNumberChartConfig,
convertToPieChartConfig,
convertToTableChartConfig,
convertToTimeChartConfig,
getPreviousDateRange,
@ -942,6 +943,8 @@ export default function EditTimeChartForm({
return convertToNumberChartConfig(config);
} else if (activeTab === 'table') {
return convertToTableChartConfig(config);
} else if (activeTab === 'pie') {
return convertToPieChartConfig(config);
}
return config;
@ -1212,24 +1215,25 @@ export default function EditTimeChartForm({
<Divider mt="md" mb="sm" />
<Flex mt={4} align="center" justify="space-between">
<Group gap="xs">
{displayType !== DisplayType.Number && (
<Button
variant="subtle"
size="sm"
color="gray"
onClick={() => {
append({
aggFn: 'count',
aggCondition: '',
aggConditionLanguage: 'lucene',
valueExpression: '',
});
}}
>
<IconCirclePlus size={14} className="me-2" />
Add Series
</Button>
)}
{displayType !== DisplayType.Number &&
displayType !== DisplayType.Pie && (
<Button
variant="subtle"
size="sm"
color="gray"
onClick={() => {
append({
aggFn: 'count',
aggCondition: '',
aggConditionLanguage: 'lucene',
valueExpression: '',
});
}}
>
<IconCirclePlus size={14} className="me-2" />
Add Series
</Button>
)}
{fields.length == 2 && displayType !== DisplayType.Number && (
<Switch
label="As Ratio"

View file

@ -1,5 +1,4 @@
import { useMemo } from 'react';
import randomUUID from 'crypto-randomuuid';
import { memo, useMemo } from 'react';
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
import { ClickHouseQueryError } from '@hyperdx/common-utils/dist/clickhouse';
import { ChartConfigWithOptTimestamp } from '@hyperdx/common-utils/dist/types';
@ -8,16 +7,45 @@ import { Box, Code, Flex, Text } from '@mantine/core';
import {
buildMVDateRangeIndicator,
convertToPieChartConfig,
formatResponseForPieChart,
} from '@/ChartUtils';
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation';
import { useSource } from '@/source';
import { COLORS } from '@/utils';
import type { NumberFormat } from '@/types';
import { getColorProps } from '@/utils';
import ChartContainer from './charts/ChartContainer';
import { ChartTooltipContainer, ChartTooltipItem } from './charts/ChartTooltip';
import MVOptimizationIndicator from './MaterializedViews/MVOptimizationIndicator';
import { SQLPreview } from './ChartSQLPreview';
const PieChartTooltip = memo(
({
active,
payload,
numberFormat,
}: {
active?: boolean;
payload?: { name: string; value: number; payload: { color: string } }[];
numberFormat?: NumberFormat;
}) => {
if (!active || !payload?.length) return null;
const entry = payload[0];
return (
<ChartTooltipContainer>
<ChartTooltipItem
color={entry.payload.color}
name={entry.name}
value={entry.value}
numberFormat={numberFormat}
indicator="none"
/>
</ChartTooltipContainer>
);
},
);
export const DBPieChart = ({
config,
title,
@ -53,22 +81,6 @@ export const DBPieChart = ({
},
);
// Returns an array of aliases, so we can check if something is using an alias
const aliasMap = useMemo(() => {
// If the config.select is a string, we can't infer this.
// One day, we could potentially run this through chSqlToAliasMap but AST parsing
// doesn't work for most DBTableChart queries.
if (typeof config.select === 'string') {
return [];
}
return config.select.reduce((acc, select) => {
if (select.alias) {
acc.push(select.alias);
}
return acc;
}, [] as string[]);
}, [config.select]);
const toolbarItemsMemo = useMemo(() => {
const allToolbarItems = [];
@ -110,74 +122,10 @@ export const DBPieChart = ({
queriedConfig,
]);
// Extract group column names from groupBy config
const groupByKeys = useMemo(() => {
if (!queriedConfig.groupBy) return [];
if (typeof queriedConfig.groupBy === 'string') {
return queriedConfig.groupBy.split(',').map(v => v.trim());
}
return queriedConfig.groupBy.map(g =>
typeof g === 'string' ? g : g.valueExpression,
);
}, [queriedConfig.groupBy]);
const pieChartData = useMemo(() => {
if (!data || data.data.length === 0) return [];
if (groupByKeys.length > 0 && data.data.length > 0) {
const groupColumnSet = new Set(groupByKeys);
return data.data.map((row, index) => {
const label =
groupByKeys.length === 1
? String(row[groupByKeys[0]])
: groupByKeys.map(key => row[key]).join(' - ');
let totalValue = 0;
for (const key in row) {
if (!groupColumnSet.has(key)) {
const numValue = parseFloat(row[key]);
if (!isNaN(numValue)) {
totalValue += numValue;
}
}
}
return {
label,
value: totalValue,
color:
index >= COLORS.length
? // Source - https://stackoverflow.com/a/5092872
'#000000'.replace(/0/g, () => {
return (~~(Math.random() * 16)).toString(16);
})
: COLORS[index],
};
});
}
if (data.data.length === 1) {
const queryData = data.data[0];
return Object.keys(queryData).map((key, index) => ({
// If it's an alias, wrap in quotes to support a variety of formats (ex "Time (ms)", "Req/s", etc)
label: aliasMap.includes(key) ? `${key}` : key,
value: parseFloat(queryData[key]),
color:
index >= COLORS.length
? // Source - https://stackoverflow.com/a/5092872
'#000000'.replace(/0/g, () => {
return (~~(Math.random() * 16)).toString(16);
})
: COLORS[index],
}));
}
return [];
}, [data, aliasMap, groupByKeys]);
if (!data) return [];
return formatResponseForPieChart(data, getColorProps);
}, [data]);
return (
<ChartContainer title={title} toolbarItems={toolbarItemsMemo}>
@ -243,7 +191,9 @@ export const DBPieChart = ({
<Cell key={entry.label} fill={entry.color} stroke="none" />
))}
</Pie>
<Tooltip />
<Tooltip
content={<PieChartTooltip numberFormat={config.numberFormat} />}
/>
</PieChart>
</ResponsiveContainer>
</Flex>

View file

@ -0,0 +1,110 @@
import { memo } from 'react';
import { IconCaretDownFilled, IconCaretUpFilled } from '@tabler/icons-react';
import type { NumberFormat } from '@/types';
import { formatNumber, truncateMiddle } from '@/utils';
import styles from '../../../styles/HDXLineChart.module.scss';
const percentFormatter = new Intl.NumberFormat('en-US', {
style: 'percent',
maximumFractionDigits: 2,
});
const calculatePercentChange = (current: number, previous: number) => {
if (previous === 0) {
return current === 0 ? 0 : undefined;
}
return (current - previous) / previous;
};
const PercentChange = ({
current,
previous,
}: {
current: number;
previous: number;
}) => {
const percentChange = calculatePercentChange(current, previous);
if (percentChange == undefined) {
return null;
}
const Icon = percentChange > 0 ? IconCaretUpFilled : IconCaretDownFilled;
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 0 }}>
(<Icon size={12} />
{percentFormatter.format(Math.abs(percentChange))})
</span>
);
};
export const ChartTooltipItem = memo(
({
color,
name,
value,
numberFormat,
indicator = 'line',
strokeDasharray,
opacity,
previous,
}: {
color: string;
name: string;
value: number;
numberFormat?: NumberFormat;
indicator?: 'line' | 'square' | 'none';
strokeDasharray?: string;
opacity?: number;
previous?: number;
}) => {
return (
<div className="d-flex gap-2 items-center justify-center">
<div>
{indicator === 'square' ? (
<svg width="12" height="12">
<rect width="12" height="12" fill={color} rx="2" />
</svg>
) : indicator === 'line' ? (
<svg width="12" height="4">
<line
x1="0"
y1="2"
x2="12"
y2="2"
stroke={color}
opacity={opacity}
strokeDasharray={strokeDasharray}
/>
</svg>
) : null}
</div>
<div>
<span style={{ color }}>{truncateMiddle(name, 50)}</span>
{': '}
{numberFormat ? formatNumber(value, numberFormat) : value}{' '}
{previous != null && (
<PercentChange current={value} previous={previous} />
)}
</div>
</div>
);
},
);
export const ChartTooltipContainer = ({
header,
children,
}: {
header?: React.ReactNode;
children: React.ReactNode;
}) => (
<div className={styles.chartTooltip}>
{header != null && (
<div className={styles.chartTooltipHeader}>{header}</div>
)}
<div className={styles.chartTooltipContent}>{children}</div>
</div>
);