feat(usage): group-by on the GPU Instances & Storage trend charts
Mirror the Tokens trend: add a clearable "Group by" select to the MetricChartCard so the GPU Instances chart can split by instance type / instance / user, and Storage by storage / user. When grouped, the chart fetches group_by=["date", "<dim>"] (the same list style as the token usage API) and pivots into one stacked series per group (shared buildTrendSeries util), with a legend; ungrouped stays a single series. group_by is now a list across the resource breakdown client; group-by options reuse the bottom-table dimensions (Users only when org-wide).
This commit is contained in:
@@ -25,15 +25,9 @@ export interface ResourceBreakdownRequest {
|
||||
end_date: string;
|
||||
scope?: 'self' | 'all';
|
||||
filters?: ResourceUsageFilters;
|
||||
group_by?:
|
||||
| 'date'
|
||||
| 'resource_type'
|
||||
| 'gpu_type'
|
||||
| 'type'
|
||||
| 'instance'
|
||||
| 'user'
|
||||
| 'volume'
|
||||
| null;
|
||||
// One or more grouping dimensions, combined left-to-right (mirrors the token
|
||||
// usage API). A trend uses ['date', '<dim>']; a table uses ['<dim>'].
|
||||
group_by?: string[];
|
||||
granularity?: 'hour' | 'day' | 'week' | 'month';
|
||||
// Server-side sort: a metric key (e.g. gpu_hours / instance_hours) +
|
||||
// direction. Defaults on the server when omitted.
|
||||
@@ -69,6 +63,9 @@ export interface ResourceBreakdownItem extends ResourceBreakdownSummary {
|
||||
volume_name?: string;
|
||||
user_id?: number;
|
||||
user_name?: string;
|
||||
// Grouped-trend rows carry the sub-group label (sku / instance / user / …)
|
||||
// alongside ``date`` so the chart can pivot one series per group.
|
||||
group?: string;
|
||||
last_active?: string;
|
||||
// Instance-type rows carry the flavor's display fields (pretty product name +
|
||||
// per-card specs) so the UI matches the GPU Instances list.
|
||||
@@ -276,6 +273,9 @@ function flattenItem(
|
||||
// Deleted entities get a "(Deleted)" suffix, matching the Token breakdown.
|
||||
const rawKey = it.key ?? undefined;
|
||||
const key = it.deleted && rawKey != null ? `${rawKey} (Deleted)` : rawKey;
|
||||
// Generic group label — for a compound (date + dim) trend row the key is the
|
||||
// sub-group value (the switch below targets single-dimension table rows).
|
||||
if (rawKey != null) flat.group = key;
|
||||
switch (groupBy) {
|
||||
case 'resource_type':
|
||||
flat.resource_type = key;
|
||||
@@ -337,14 +337,16 @@ function flattenResponse(
|
||||
}
|
||||
|
||||
function toServerRequest(data: ResourceBreakdownRequest) {
|
||||
const groupBy = data.group_by ?? 'resource_type';
|
||||
const groupByList = data.group_by?.length ? data.group_by : ['resource_type'];
|
||||
const { creator_ids, instance_ids, volume_ids } = data.filters ?? {};
|
||||
// The non-date dimension drives response flattening into the right field.
|
||||
const dim = groupByList.find((g) => g !== 'date');
|
||||
return {
|
||||
body: {
|
||||
start_date: data.start_date,
|
||||
end_date: data.end_date,
|
||||
scope: data.scope ?? 'all',
|
||||
group_by: GROUP_BY_MAP[groupBy] ?? groupBy,
|
||||
group_by: groupByList.map((g) => GROUP_BY_MAP[g] ?? g),
|
||||
granularity: data.granularity ?? 'day',
|
||||
// POST endpoints take proper id arrays. "filter by user" + "filter by
|
||||
// resource" (instance ids on the GPU tab / volume ids on Storage).
|
||||
@@ -356,7 +358,7 @@ function toServerRequest(data: ResourceBreakdownRequest) {
|
||||
page: data.page ?? 1,
|
||||
perPage: data.perPage ?? 20
|
||||
},
|
||||
groupBy
|
||||
groupBy: dim
|
||||
};
|
||||
}
|
||||
|
||||
@@ -488,7 +490,7 @@ export async function queryUsageSummary(params: {
|
||||
start_date: params.start_date,
|
||||
end_date: params.end_date,
|
||||
scope: params.scope ?? 'all',
|
||||
group_by: 'gpu_type',
|
||||
group_by: ['gpu_type'],
|
||||
...(creator_ids?.length ? { filters: { creator_ids } } : {}),
|
||||
page: 1,
|
||||
perPage: 100
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
generateBucketRange,
|
||||
Granularity
|
||||
} from '../utils/time-buckets';
|
||||
import { buildTrendSeries } from '../utils/trend-series';
|
||||
import MetricChartCard from './metric-chart-card';
|
||||
import MetricLabel from './metric-label';
|
||||
import ResourceExportData from './resource-export-data';
|
||||
@@ -75,10 +76,11 @@ const GpuInstancesTab: React.FC = () => {
|
||||
],
|
||||
[intl]
|
||||
);
|
||||
// ``useCoolColors`` returns a memoized factory; resolve it once into a
|
||||
// fixed 5-slot palette here so the rest of the component reads as
|
||||
// array access.
|
||||
const coolColors = useCoolColors()(5);
|
||||
// ``useCoolColors`` returns a memoized factory; resolve a fixed 5-slot
|
||||
// palette for the KPI cards, and keep the factory for the grouped trend
|
||||
// (sized to the group count).
|
||||
const colorFactory = useCoolColors();
|
||||
const coolColors = colorFactory(5);
|
||||
|
||||
// No All/My dropdown (matches the Tokens tab): managers see the org-wide
|
||||
// view and narrow it with the user filter, others only their own rows.
|
||||
@@ -94,6 +96,8 @@ const GpuInstancesTab: React.FC = () => {
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [metric, setMetric] = useState<Metric>('gpu_hours');
|
||||
const [granularity, setGranularity] = useState<Granularity>('day');
|
||||
// Optional trend group-by (split the chart into one series per group).
|
||||
const [chartGroupBy, setChartGroupBy] = useState<GroupKey | null>(null);
|
||||
// ``null`` group_by = no row grouping, just the summary KPIs.
|
||||
// The chart needs the ``date`` group; tables use the active table tab.
|
||||
const [activeTableTab, setActiveTableTab] = useState<GroupKey>('gpu_type');
|
||||
@@ -139,7 +143,10 @@ const GpuInstancesTab: React.FC = () => {
|
||||
try {
|
||||
const data = await queryGpuInstancesBreakdown({
|
||||
...baseRequest(),
|
||||
group_by: 'date'
|
||||
// Split each bucket by the chosen dimension when grouping; fetch the
|
||||
// whole range (date × groups can exceed a normal page).
|
||||
group_by: chartGroupBy ? ['date', chartGroupBy] : ['date'],
|
||||
...(chartGroupBy ? { perPage: 10000 } : {})
|
||||
});
|
||||
setChartData(data);
|
||||
} catch {
|
||||
@@ -152,7 +159,7 @@ const GpuInstancesTab: React.FC = () => {
|
||||
try {
|
||||
const data = await queryGpuInstancesBreakdown({
|
||||
...baseRequest(),
|
||||
group_by: activeTableTab,
|
||||
group_by: [activeTableTab],
|
||||
page: tablePage,
|
||||
order_by: tableSort.field,
|
||||
descending: tableSort.order === 'descend'
|
||||
@@ -165,7 +172,14 @@ const GpuInstancesTab: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
fetchChart();
|
||||
}, [dateRange, selectedUsers, selectedInstances, granularity, refreshKey]);
|
||||
}, [
|
||||
dateRange,
|
||||
selectedUsers,
|
||||
selectedInstances,
|
||||
granularity,
|
||||
chartGroupBy,
|
||||
refreshKey
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTable();
|
||||
@@ -224,16 +238,7 @@ const GpuInstancesTab: React.FC = () => {
|
||||
[summary, coolColors, intl]
|
||||
);
|
||||
|
||||
// Build chart series — single series of the selected metric, plotted
|
||||
// along the contiguous date range.
|
||||
const dataByDate = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
chartData?.items?.forEach((item) => {
|
||||
if (!item.date) return;
|
||||
map.set(bucketKey(item.date, granularity), Number(item[metric] ?? 0));
|
||||
});
|
||||
return map;
|
||||
}, [chartData, metric, granularity]);
|
||||
// x-axis = the contiguous date range plus any buckets present in the data.
|
||||
const xAxis = useMemo(() => {
|
||||
const keys = new Set(
|
||||
generateBucketRange(
|
||||
@@ -242,17 +247,40 @@ const GpuInstancesTab: React.FC = () => {
|
||||
granularity
|
||||
)
|
||||
);
|
||||
dataByDate.forEach((_v, k) => keys.add(k));
|
||||
chartData?.items?.forEach((i) => {
|
||||
if (i.date) keys.add(bucketKey(i.date, granularity));
|
||||
});
|
||||
return Array.from(keys).sort();
|
||||
}, [dataByDate, dateRange, granularity]);
|
||||
}, [chartData, dateRange, granularity]);
|
||||
|
||||
const seriesData = [
|
||||
{
|
||||
name: METRIC_OPTIONS.find((m) => m.value === metric)?.label || metric,
|
||||
data: xAxis.map((d) => dataByDate.get(d) ?? 0),
|
||||
color: coolColors[0]
|
||||
}
|
||||
];
|
||||
// Single series, or one stacked series per group when grouping is on.
|
||||
const seriesData = useMemo(
|
||||
() =>
|
||||
buildTrendSeries({
|
||||
items: chartData?.items,
|
||||
metric,
|
||||
granularity,
|
||||
xAxis,
|
||||
groupBy: chartGroupBy,
|
||||
palette: colorFactory,
|
||||
singleName:
|
||||
METRIC_OPTIONS.find((m) => m.value === metric)?.label || metric
|
||||
}),
|
||||
[chartData, metric, granularity, xAxis, chartGroupBy, colorFactory]
|
||||
);
|
||||
|
||||
// Group-by options for the trend = the same dimensions as the bottom tables
|
||||
// (Users only when org-wide, matching the table tabs).
|
||||
const chartGroupByOptions = useMemo(
|
||||
() =>
|
||||
TABLE_TABS.filter((t) => t.key !== 'user' || scope === 'all').map(
|
||||
(t) => ({
|
||||
value: t.key,
|
||||
label: t.label
|
||||
})
|
||||
),
|
||||
[TABLE_TABS, scope]
|
||||
);
|
||||
|
||||
// Table columns adapt to the active tab.
|
||||
const tableColumns = useMemo(() => {
|
||||
@@ -392,13 +420,13 @@ const GpuInstancesTab: React.FC = () => {
|
||||
const exportConfig =
|
||||
exportMode === 'chart'
|
||||
? {
|
||||
groupBy: 'date' as const,
|
||||
groupBy: ['date'],
|
||||
columns: chartExportColumns,
|
||||
fileName: `gpu-instances_chart_${dateSuffix}.xlsx`,
|
||||
sheetName: intl.formatMessage({ id: 'usage.tabs.gpuInstances' })
|
||||
}
|
||||
: {
|
||||
groupBy: activeTableTab,
|
||||
groupBy: [activeTableTab],
|
||||
columns: tableColumns,
|
||||
fileName: `gpu-instances_${activeTableTab}_${dateSuffix}.xlsx`,
|
||||
sheetName: tabLabel || 'gpu-instances'
|
||||
@@ -459,6 +487,9 @@ const GpuInstancesTab: React.FC = () => {
|
||||
onGranularityChange={(v) => setGranularity(v as Granularity)}
|
||||
seriesData={seriesData}
|
||||
xAxisData={xAxis}
|
||||
groupBy={chartGroupBy}
|
||||
groupByOptions={chartGroupByOptions}
|
||||
onGroupByChange={(v) => setChartGroupBy(v as GroupKey | null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -47,6 +47,11 @@ interface MetricChartCardProps {
|
||||
onGranularityChange: (value: string) => void;
|
||||
seriesData: BarSeriesItem[];
|
||||
xAxisData: string[];
|
||||
// Optional group-by control (mirrors the Tokens trend). When provided, a
|
||||
// clearable "Group by" select is shown; clearing passes null.
|
||||
groupBy?: string | null;
|
||||
groupByOptions?: MetricOption[];
|
||||
onGroupByChange?: (value: string | null) => void;
|
||||
}
|
||||
|
||||
const MetricChartCard: React.FC<MetricChartCardProps> = ({
|
||||
@@ -56,7 +61,10 @@ const MetricChartCard: React.FC<MetricChartCardProps> = ({
|
||||
onMetricChange,
|
||||
onGranularityChange,
|
||||
seriesData,
|
||||
xAxisData
|
||||
xAxisData,
|
||||
groupBy,
|
||||
groupByOptions,
|
||||
onGroupByChange
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
@@ -84,6 +92,22 @@ const MetricChartCard: React.FC<MetricChartCardProps> = ({
|
||||
onChange={onMetricChange}
|
||||
style={{ width: 'max-content' }}
|
||||
/>
|
||||
{groupByOptions?.length && onGroupByChange ? (
|
||||
<BaseSelect
|
||||
allowClear
|
||||
variant="borderless"
|
||||
prefix={
|
||||
<ControlLabel>
|
||||
{intl.formatMessage({ id: 'usage.filter.groupBy' })}
|
||||
</ControlLabel>
|
||||
}
|
||||
options={groupByOptions}
|
||||
value={groupBy ?? undefined}
|
||||
popupMatchSelectWidth={false}
|
||||
onChange={(v: string) => onGroupByChange(v || null)}
|
||||
style={{ width: 'max-content', minWidth: 140 }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<Segmented
|
||||
size="small"
|
||||
@@ -109,6 +133,12 @@ const MetricChartCard: React.FC<MetricChartCardProps> = ({
|
||||
xAxisData={xAxisData}
|
||||
height={280}
|
||||
labelFormatter={labelFormatter}
|
||||
// Auto-show a legend once the trend is split into multiple series.
|
||||
legendData={
|
||||
seriesData.length > 1
|
||||
? seriesData.map((s) => ({ name: s.name }))
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</CardWrapper>
|
||||
);
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
generateBucketRange,
|
||||
Granularity
|
||||
} from '../utils/time-buckets';
|
||||
import { buildTrendSeries } from '../utils/trend-series';
|
||||
import MetricChartCard from './metric-chart-card';
|
||||
import MetricLabel from './metric-label';
|
||||
import ResourceExportData from './resource-export-data';
|
||||
@@ -42,7 +43,10 @@ type GroupKey = 'volume' | 'user';
|
||||
const StorageTab: React.FC = () => {
|
||||
const access = useAccess();
|
||||
const intl = useIntl();
|
||||
const coolColors = useCoolColors()(4);
|
||||
// Factory kept for the grouped trend (sized to group count); 4-slot palette
|
||||
// for the KPI cards.
|
||||
const colorFactory = useCoolColors();
|
||||
const coolColors = colorFactory(4);
|
||||
|
||||
const METRIC_OPTIONS: { value: Metric; label: string }[] = useMemo(
|
||||
() => [
|
||||
@@ -83,6 +87,8 @@ const StorageTab: React.FC = () => {
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [metric, setMetric] = useState<Metric>('storage_gb_days');
|
||||
const [granularity, setGranularity] = useState<Granularity>('day');
|
||||
// Optional trend group-by (split the chart into one series per group).
|
||||
const [chartGroupBy, setChartGroupBy] = useState<GroupKey | null>(null);
|
||||
const [activeTableTab, setActiveTableTab] = useState<GroupKey>('volume');
|
||||
|
||||
const { creators: userOptions, volumes: volumeOptions } =
|
||||
@@ -121,7 +127,10 @@ const StorageTab: React.FC = () => {
|
||||
try {
|
||||
const data = await queryStorageBreakdown({
|
||||
...baseRequest(),
|
||||
group_by: 'date'
|
||||
// Split each bucket by the chosen dimension when grouping; fetch the
|
||||
// whole range (date × groups can exceed a normal page).
|
||||
group_by: chartGroupBy ? ['date', chartGroupBy] : ['date'],
|
||||
...(chartGroupBy ? { perPage: 10000 } : {})
|
||||
});
|
||||
setChartData(data);
|
||||
} catch {
|
||||
@@ -140,7 +149,7 @@ const StorageTab: React.FC = () => {
|
||||
try {
|
||||
const data = await queryStorageBreakdown({
|
||||
...baseRequest(),
|
||||
group_by: activeTableTab,
|
||||
group_by: [activeTableTab],
|
||||
page: tablePage,
|
||||
order_by: ORDER_BY_KEY[tableSort.field],
|
||||
descending: tableSort.order === 'descend'
|
||||
@@ -153,7 +162,14 @@ const StorageTab: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
fetchChart();
|
||||
}, [dateRange, selectedUsers, selectedVolumes, granularity, refreshKey]);
|
||||
}, [
|
||||
dateRange,
|
||||
selectedUsers,
|
||||
selectedVolumes,
|
||||
granularity,
|
||||
chartGroupBy,
|
||||
refreshKey
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTable();
|
||||
@@ -208,14 +224,6 @@ const StorageTab: React.FC = () => {
|
||||
[summary, coolColors, intl]
|
||||
);
|
||||
|
||||
const dataByDate = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
chartData?.items?.forEach((item) => {
|
||||
if (!item.date) return;
|
||||
map.set(bucketKey(item.date, granularity), Number(item[metric] ?? 0));
|
||||
});
|
||||
return map;
|
||||
}, [chartData, metric, granularity]);
|
||||
const xAxis = useMemo(() => {
|
||||
const keys = new Set(
|
||||
generateBucketRange(
|
||||
@@ -224,17 +232,45 @@ const StorageTab: React.FC = () => {
|
||||
granularity
|
||||
)
|
||||
);
|
||||
dataByDate.forEach((_v, k) => keys.add(k));
|
||||
chartData?.items?.forEach((i) => {
|
||||
if (i.date) keys.add(bucketKey(i.date, granularity));
|
||||
});
|
||||
return Array.from(keys).sort();
|
||||
}, [dataByDate, dateRange, granularity]);
|
||||
}, [chartData, dateRange, granularity]);
|
||||
|
||||
const seriesData = [
|
||||
{
|
||||
name: METRIC_OPTIONS.find((m) => m.value === metric)?.label || metric,
|
||||
data: xAxis.map((d) => dataByDate.get(d) ?? 0),
|
||||
color: coolColors[0]
|
||||
}
|
||||
];
|
||||
const seriesData = useMemo(
|
||||
() =>
|
||||
buildTrendSeries({
|
||||
items: chartData?.items,
|
||||
metric,
|
||||
granularity,
|
||||
xAxis,
|
||||
groupBy: chartGroupBy,
|
||||
palette: colorFactory,
|
||||
singleName:
|
||||
METRIC_OPTIONS.find((m) => m.value === metric)?.label || metric
|
||||
}),
|
||||
[
|
||||
chartData,
|
||||
metric,
|
||||
granularity,
|
||||
xAxis,
|
||||
chartGroupBy,
|
||||
colorFactory,
|
||||
METRIC_OPTIONS
|
||||
]
|
||||
);
|
||||
|
||||
const chartGroupByOptions = useMemo(
|
||||
() =>
|
||||
TABLE_TABS.filter((t) => t.key !== 'user' || scope === 'all').map(
|
||||
(t) => ({
|
||||
value: t.key,
|
||||
label: t.label
|
||||
})
|
||||
),
|
||||
[TABLE_TABS, scope]
|
||||
);
|
||||
|
||||
const tableColumns = useMemo(() => {
|
||||
const valueCols = [
|
||||
@@ -349,13 +385,13 @@ const StorageTab: React.FC = () => {
|
||||
const exportConfig =
|
||||
exportMode === 'chart'
|
||||
? {
|
||||
groupBy: 'date' as const,
|
||||
groupBy: ['date'],
|
||||
columns: chartExportColumns,
|
||||
fileName: `storage_chart_${dateSuffix}.xlsx`,
|
||||
sheetName: intl.formatMessage({ id: 'usage.tabs.storage' })
|
||||
}
|
||||
: {
|
||||
groupBy: activeTableTab,
|
||||
groupBy: [activeTableTab],
|
||||
columns: tableColumns,
|
||||
fileName: `storage_${activeTableTab}_${dateSuffix}.xlsx`,
|
||||
sheetName: tabLabel || 'storage'
|
||||
@@ -413,6 +449,9 @@ const StorageTab: React.FC = () => {
|
||||
onGranularityChange={(v) => setGranularity(v as Granularity)}
|
||||
seriesData={seriesData}
|
||||
xAxisData={xAxis}
|
||||
groupBy={chartGroupBy}
|
||||
groupByOptions={chartGroupByOptions}
|
||||
onGroupByChange={(v) => setChartGroupBy(v as GroupKey | null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -258,7 +258,7 @@ const SummaryTab: React.FC = () => {
|
||||
start_date: start,
|
||||
end_date: end,
|
||||
scope,
|
||||
group_by: 'type',
|
||||
group_by: ['type'],
|
||||
filters: creatorFilter,
|
||||
page: 1,
|
||||
perPage: 100
|
||||
@@ -284,7 +284,7 @@ const SummaryTab: React.FC = () => {
|
||||
start_date: start,
|
||||
end_date: end,
|
||||
scope,
|
||||
group_by: 'date',
|
||||
group_by: ['date'],
|
||||
granularity,
|
||||
filters: creatorFilter,
|
||||
page: 1,
|
||||
@@ -296,7 +296,7 @@ const SummaryTab: React.FC = () => {
|
||||
start_date: start,
|
||||
end_date: end,
|
||||
scope,
|
||||
group_by: 'date',
|
||||
group_by: ['date'],
|
||||
granularity,
|
||||
filters: creatorFilter,
|
||||
page: 1,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Build bar-chart series for the resource trend charts (GPU Instances /
|
||||
* Storage), shared so both tabs behave identically.
|
||||
*
|
||||
* Without a group dimension it's a single series of the chosen metric. With
|
||||
* one (the backend returns ``group_by=["date", "<dim>"]`` rows, each carrying
|
||||
* ``date`` + ``group``), it pivots into one stacked series per group value
|
||||
* over the date axis — mirroring the Tokens tab's grouped trend.
|
||||
*/
|
||||
import type { BarSeriesItem } from '@/pages/_components/bar-chart';
|
||||
import { ResourceBreakdownItem } from '../apis/resource';
|
||||
import { bucketKey, Granularity } from './time-buckets';
|
||||
|
||||
const valueOf = (item: ResourceBreakdownItem, metric: string): number =>
|
||||
Number((item as Record<string, any>)[metric] ?? 0);
|
||||
|
||||
export const buildTrendSeries = (opts: {
|
||||
items?: ResourceBreakdownItem[];
|
||||
metric: string;
|
||||
granularity: Granularity;
|
||||
xAxis: string[];
|
||||
groupBy: string | null;
|
||||
// Palette factory (e.g. useCoolColors()); called with the series count.
|
||||
palette: (n: number) => string[];
|
||||
singleName: string;
|
||||
}): BarSeriesItem[] => {
|
||||
const { items, metric, granularity, xAxis, groupBy, palette, singleName } =
|
||||
opts;
|
||||
|
||||
if (!groupBy) {
|
||||
const byDate = new Map<string, number>();
|
||||
(items || []).forEach((i) => {
|
||||
if (!i.date) return;
|
||||
byDate.set(bucketKey(i.date, granularity), valueOf(i, metric));
|
||||
});
|
||||
return [
|
||||
{
|
||||
name: singleName,
|
||||
data: xAxis.map((d) => byDate.get(d) ?? 0),
|
||||
color: palette(1)[0]
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
// Group label → (date bucket → value). ``order`` preserves first-seen order
|
||||
// so colors stay stable as the date range scrolls.
|
||||
const byGroup = new Map<string, Map<string, number>>();
|
||||
const order: string[] = [];
|
||||
(items || []).forEach((i) => {
|
||||
if (!i.date) return;
|
||||
const label = i.group || 'unknown';
|
||||
if (!byGroup.has(label)) {
|
||||
byGroup.set(label, new Map());
|
||||
order.push(label);
|
||||
}
|
||||
byGroup.get(label)!.set(bucketKey(i.date, granularity), valueOf(i, metric));
|
||||
});
|
||||
|
||||
const colors = palette(Math.max(order.length, 1));
|
||||
return order.map((label, idx) => ({
|
||||
name: label,
|
||||
data: xAxis.map((d) => byGroup.get(label)?.get(d) ?? 0),
|
||||
color: colors[idx % colors.length],
|
||||
stack: 'total'
|
||||
}));
|
||||
};
|
||||
Reference in New Issue
Block a user