Skip to content

Commit 7e5a0cb

Browse files
committed
Added aggregation mode
1 parent 5cef0b4 commit 7e5a0cb

File tree

2 files changed

+141
-47
lines changed

2 files changed

+141
-47
lines changed

apps/webapp/app/components/code/ChartConfigPanel.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Button } from "../primitives/Buttons";
1010

1111
export type ChartType = "bar" | "line";
1212
export type SortDirection = "asc" | "desc";
13+
export type AggregationType = "sum" | "avg" | "count" | "min" | "max";
1314

1415
export interface ChartConfiguration {
1516
chartType: ChartType;
@@ -19,6 +20,7 @@ export interface ChartConfiguration {
1920
stacked: boolean;
2021
sortByColumn: string | null;
2122
sortDirection: SortDirection;
23+
aggregation: AggregationType;
2224
}
2325

2426
export const defaultChartConfig: ChartConfiguration = {
@@ -29,6 +31,7 @@ export const defaultChartConfig: ChartConfiguration = {
2931
stacked: false,
3032
sortByColumn: null,
3133
sortDirection: "asc",
34+
aggregation: "sum",
3235
};
3336

3437
interface ChartConfigPanelProps {
@@ -192,6 +195,15 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
192195
}));
193196
}, [numericColumns]);
194197

198+
// Aggregation options
199+
const aggregationOptions = [
200+
{ value: "sum", label: "Sum" },
201+
{ value: "avg", label: "Average" },
202+
{ value: "count", label: "Count" },
203+
{ value: "min", label: "Min" },
204+
{ value: "max", label: "Max" },
205+
];
206+
195207
// Group by options: categorical columns (excluding selected X axis)
196208
const groupByOptions = useMemo(() => {
197209
const options = categoricalColumns
@@ -338,6 +350,26 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
338350
)}
339351
</ConfigField>
340352

353+
{/* Aggregation */}
354+
<ConfigField label="Aggregation">
355+
<Select
356+
value={config.aggregation}
357+
setValue={(value) => updateConfig({ aggregation: value as AggregationType })}
358+
variant="tertiary/small"
359+
items={aggregationOptions}
360+
dropdownIcon
361+
className="min-w-[100px]"
362+
>
363+
{(items) =>
364+
items.map((item) => (
365+
<SelectItem key={item.value} value={item.value}>
366+
{item.label}
367+
</SelectItem>
368+
))
369+
}
370+
</Select>
371+
</ConfigField>
372+
341373
{/* Group By */}
342374
<ConfigField label="Group by">
343375
<Select

apps/webapp/app/components/code/QueryResultsChart.tsx

Lines changed: 109 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
ChartTooltipContent,
2121
} from "~/components/primitives/Chart";
2222
import { Paragraph } from "../primitives/Paragraph";
23-
import type { ChartConfiguration } from "./ChartConfigPanel";
23+
import type { AggregationType, ChartConfiguration } from "./ChartConfigPanel";
2424

2525
// Color palette for chart series
2626
const CHART_COLORS = [
@@ -198,6 +198,7 @@ function fillTimeGaps(
198198
maxTime: number,
199199
interval: number,
200200
granularity: TimeGranularity,
201+
aggregation: AggregationType,
201202
maxPoints = 1000
202203
): Record<string, unknown>[] {
203204
const range = maxTime - minTime;
@@ -222,40 +223,52 @@ function fillTimeGaps(
222223
else effectiveInterval = 24 * HOUR;
223224
}
224225

225-
// Create a map of existing data points by timestamp (bucketed to the effective interval)
226-
const existingData = new Map<number, Record<string, unknown>>();
226+
// Create a map to collect values for each bucket (for aggregation)
227+
const bucketData = new Map<
228+
number,
229+
{ values: Record<string, number[]>; rawDate: Date; originalX: string }
230+
>();
231+
227232
for (const point of data) {
228233
const timestamp = point[xDataKey] as number;
229234
// Bucket to the nearest interval
230235
const bucketedTime = Math.floor(timestamp / effectiveInterval) * effectiveInterval;
231236

232-
// If there's already data for this bucket, aggregate it
233-
const existing = existingData.get(bucketedTime);
234-
if (existing) {
235-
// Sum the values
236-
for (const s of series) {
237-
const existingVal = (existing[s] as number) || 0;
238-
const newVal = (point[s] as number) || 0;
239-
existing[s] = existingVal + newVal;
240-
}
241-
} else {
242-
// Clone the point with the bucketed timestamp
243-
existingData.set(bucketedTime, {
244-
...point,
245-
[xDataKey]: bucketedTime,
246-
__rawDate: new Date(bucketedTime),
237+
if (!bucketData.has(bucketedTime)) {
238+
bucketData.set(bucketedTime, {
239+
values: Object.fromEntries(series.map((s) => [s, []])),
240+
rawDate: new Date(bucketedTime),
241+
originalX: new Date(bucketedTime).toISOString(),
247242
});
248243
}
244+
245+
const bucket = bucketData.get(bucketedTime)!;
246+
for (const s of series) {
247+
const val = point[s] as number;
248+
if (typeof val === "number") {
249+
bucket.values[s].push(val);
250+
}
251+
}
249252
}
250253

251254
// Generate all time slots and fill with zeros where missing
252255
const filledData: Record<string, unknown>[] = [];
253256
const startTime = Math.floor(minTime / effectiveInterval) * effectiveInterval;
254257

255258
for (let t = startTime; t <= maxTime; t += effectiveInterval) {
256-
const existing = existingData.get(t);
257-
if (existing) {
258-
filledData.push(existing);
259+
const bucket = bucketData.get(t);
260+
if (bucket) {
261+
// Apply aggregation to collected values
262+
const point: Record<string, unknown> = {
263+
[xDataKey]: t,
264+
__rawDate: bucket.rawDate,
265+
__granularity: granularity,
266+
__originalX: bucket.originalX,
267+
};
268+
for (const s of series) {
269+
point[s] = aggregateValues(bucket.values[s], aggregation);
270+
}
271+
filledData.push(point);
259272
} else {
260273
// Create a zero-filled data point
261274
const zeroPoint: Record<string, unknown> = {
@@ -441,7 +454,7 @@ function transformDataForChart(
441454
rows: Record<string, unknown>[],
442455
config: ChartConfiguration
443456
): TransformedData {
444-
const { xAxisColumn, yAxisColumns, groupByColumn } = config;
457+
const { xAxisColumn, yAxisColumns, groupByColumn, aggregation } = config;
445458

446459
if (!xAxisColumn || yAxisColumns.length === 0) {
447460
return {
@@ -492,25 +505,49 @@ function transformDataForChart(
492505
};
493506

494507
// No grouping: use Y columns directly as series
508+
// Group rows by X value first, then aggregate
495509
if (!groupByColumn) {
496-
let data = rows
497-
.map((row) => {
498-
const rawDate = tryParseDate(row[xAxisColumn]);
499-
const point: Record<string, unknown> = {
500-
// For date-based, use timestamp; otherwise use formatted string
501-
[xDataKey]: isDateBased && rawDate ? rawDate.getTime() : formatX(row[xAxisColumn]),
502-
// Store raw date and original value for tooltip
503-
__rawDate: rawDate,
504-
__granularity: granularity,
505-
__originalX: row[xAxisColumn],
506-
};
507-
for (const yCol of yAxisColumns) {
508-
point[yCol] = toNumber(row[yCol]);
509-
}
510-
return point;
511-
})
512-
// Filter out rows with invalid dates for date-based axes
513-
.filter((point) => !isDateBased || point.__rawDate !== null);
510+
// Group rows by X-axis value to handle duplicates
511+
const groupedByX = new Map<
512+
string | number,
513+
{ yValues: Record<string, number[]>; rawDate: Date | null; originalX: unknown }
514+
>();
515+
516+
for (const row of rows) {
517+
const rawDate = tryParseDate(row[xAxisColumn]);
518+
519+
// Skip rows with invalid dates for date-based axes
520+
if (isDateBased && !rawDate) continue;
521+
522+
const xKey = isDateBased && rawDate ? rawDate.getTime() : formatX(row[xAxisColumn]);
523+
524+
if (!groupedByX.has(xKey)) {
525+
groupedByX.set(xKey, {
526+
yValues: Object.fromEntries(yAxisColumns.map((col) => [col, []])),
527+
rawDate,
528+
originalX: row[xAxisColumn],
529+
});
530+
}
531+
532+
const existing = groupedByX.get(xKey)!;
533+
for (const yCol of yAxisColumns) {
534+
existing.yValues[yCol].push(toNumber(row[yCol]));
535+
}
536+
}
537+
538+
// Convert to array format with aggregation applied
539+
let data = Array.from(groupedByX.entries()).map(([xKey, { yValues, rawDate, originalX }]) => {
540+
const point: Record<string, unknown> = {
541+
[xDataKey]: xKey,
542+
__rawDate: rawDate,
543+
__granularity: granularity,
544+
__originalX: originalX,
545+
};
546+
for (const yCol of yAxisColumns) {
547+
point[yCol] = aggregateValues(yValues[yCol], aggregation);
548+
}
549+
return point;
550+
});
514551

515552
// Fill in gaps with zeros for date-based data
516553
if (isDateBased && timeDomain) {
@@ -523,7 +560,8 @@ function transformDataForChart(
523560
timeDomain[0],
524561
timeDomain[1],
525562
dataInterval,
526-
granularity
563+
granularity,
564+
aggregation
527565
);
528566
}
529567

@@ -535,9 +573,10 @@ function transformDataForChart(
535573
const groupValues = new Set<string>();
536574

537575
// For date-based, key by timestamp; otherwise by formatted string
576+
// Collect all values for aggregation
538577
const groupedByX = new Map<
539578
string | number,
540-
{ values: Record<string, number>; rawDate: Date | null; originalX: unknown }
579+
{ values: Record<string, number[]>; rawDate: Date | null; originalX: unknown }
541580
>();
542581

543582
for (const row of rows) {
@@ -557,11 +596,14 @@ function transformDataForChart(
557596
}
558597

559598
const existing = groupedByX.get(xKey)!;
560-
// Sum values if there are multiple rows with same x + group
561-
existing.values[groupValue] = (existing.values[groupValue] ?? 0) + yValue;
599+
// Collect values for aggregation
600+
if (!existing.values[groupValue]) {
601+
existing.values[groupValue] = [];
602+
}
603+
existing.values[groupValue].push(yValue);
562604
}
563605

564-
// Convert to array format
606+
// Convert to array format with aggregation applied
565607
const series = Array.from(groupValues).sort();
566608
let data = Array.from(groupedByX.entries()).map(([xKey, { values, rawDate, originalX }]) => {
567609
const point: Record<string, unknown> = {
@@ -571,7 +613,7 @@ function transformDataForChart(
571613
__originalX: originalX,
572614
};
573615
for (const group of series) {
574-
point[group] = values[group] ?? 0;
616+
point[group] = values[group] ? aggregateValues(values[group], aggregation) : 0;
575617
}
576618
return point;
577619
});
@@ -587,7 +629,8 @@ function transformDataForChart(
587629
timeDomain[0],
588630
timeDomain[1],
589631
dataInterval,
590-
granularity
632+
granularity,
633+
aggregation
591634
);
592635
}
593636

@@ -603,6 +646,25 @@ function toNumber(value: unknown): number {
603646
return 0;
604647
}
605648

649+
/**
650+
* Aggregate an array of numbers using the specified aggregation function
651+
*/
652+
function aggregateValues(values: number[], aggregation: AggregationType): number {
653+
if (values.length === 0) return 0;
654+
switch (aggregation) {
655+
case "sum":
656+
return values.reduce((a, b) => a + b, 0);
657+
case "avg":
658+
return values.reduce((a, b) => a + b, 0) / values.length;
659+
case "count":
660+
return values.length;
661+
case "min":
662+
return Math.min(...values);
663+
case "max":
664+
return Math.max(...values);
665+
}
666+
}
667+
606668
/**
607669
* Sort data array by a specified column
608670
*/

0 commit comments

Comments
 (0)