Skip to content

Commit ad3f65f

Browse files
committed
Fill in blank data for graphs
1 parent 3332b37 commit ad3f65f

File tree

1 file changed

+164
-2
lines changed

1 file changed

+164
-2
lines changed

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

Lines changed: 164 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,138 @@ function formatDateByGranularity(date: Date, granularity: TimeGranularity): stri
142142
}
143143
}
144144

145+
/**
146+
* Detect the most common interval between consecutive data points
147+
* This helps us understand the natural granularity of the data
148+
*/
149+
function detectDataInterval(timestamps: number[]): number {
150+
if (timestamps.length < 2) return 60 * 1000; // Default to 1 minute
151+
152+
const sorted = [...timestamps].sort((a, b) => a - b);
153+
const gaps: number[] = [];
154+
155+
for (let i = 1; i < sorted.length; i++) {
156+
const gap = sorted[i] - sorted[i - 1];
157+
if (gap > 0) {
158+
gaps.push(gap);
159+
}
160+
}
161+
162+
if (gaps.length === 0) return 60 * 1000;
163+
164+
// Find the most common small gap (this is likely the data's natural interval)
165+
// We use the minimum gap as a heuristic for the data interval
166+
const minGap = Math.min(...gaps);
167+
168+
// Round to a nice interval
169+
const MINUTE = 60 * 1000;
170+
const HOUR = 60 * MINUTE;
171+
const DAY = 24 * HOUR;
172+
173+
// Snap to common intervals
174+
if (minGap <= MINUTE) return MINUTE;
175+
if (minGap <= 5 * MINUTE) return 5 * MINUTE;
176+
if (minGap <= 10 * MINUTE) return 10 * MINUTE;
177+
if (minGap <= 15 * MINUTE) return 15 * MINUTE;
178+
if (minGap <= 30 * MINUTE) return 30 * MINUTE;
179+
if (minGap <= HOUR) return HOUR;
180+
if (minGap <= 2 * HOUR) return 2 * HOUR;
181+
if (minGap <= 4 * HOUR) return 4 * HOUR;
182+
if (minGap <= 6 * HOUR) return 6 * HOUR;
183+
if (minGap <= 12 * HOUR) return 12 * HOUR;
184+
if (minGap <= DAY) return DAY;
185+
186+
return minGap;
187+
}
188+
189+
/**
190+
* Fill in missing time slots with zero values
191+
* This ensures the chart shows gaps as zeros rather than connecting distant points
192+
*/
193+
function fillTimeGaps(
194+
data: Record<string, unknown>[],
195+
xDataKey: string,
196+
series: string[],
197+
minTime: number,
198+
maxTime: number,
199+
interval: number,
200+
granularity: TimeGranularity,
201+
maxPoints = 1000
202+
): Record<string, unknown>[] {
203+
const range = maxTime - minTime;
204+
const estimatedPoints = Math.ceil(range / interval);
205+
206+
// If filling would create too many points, increase the interval to stay within limits
207+
let effectiveInterval = interval;
208+
if (estimatedPoints > maxPoints) {
209+
effectiveInterval = Math.ceil(range / maxPoints);
210+
// Round up to a nice interval
211+
const MINUTE = 60 * 1000;
212+
const HOUR = 60 * MINUTE;
213+
if (effectiveInterval < 5 * MINUTE) effectiveInterval = 5 * MINUTE;
214+
else if (effectiveInterval < 10 * MINUTE) effectiveInterval = 10 * MINUTE;
215+
else if (effectiveInterval < 15 * MINUTE) effectiveInterval = 15 * MINUTE;
216+
else if (effectiveInterval < 30 * MINUTE) effectiveInterval = 30 * MINUTE;
217+
else if (effectiveInterval < HOUR) effectiveInterval = HOUR;
218+
else if (effectiveInterval < 2 * HOUR) effectiveInterval = 2 * HOUR;
219+
else if (effectiveInterval < 4 * HOUR) effectiveInterval = 4 * HOUR;
220+
else if (effectiveInterval < 6 * HOUR) effectiveInterval = 6 * HOUR;
221+
else if (effectiveInterval < 12 * HOUR) effectiveInterval = 12 * HOUR;
222+
else effectiveInterval = 24 * HOUR;
223+
}
224+
225+
// Create a map of existing data points by timestamp (bucketed to the effective interval)
226+
const existingData = new Map<number, Record<string, unknown>>();
227+
for (const point of data) {
228+
const timestamp = point[xDataKey] as number;
229+
// Bucket to the nearest interval
230+
const bucketedTime = Math.floor(timestamp / effectiveInterval) * effectiveInterval;
231+
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),
247+
});
248+
}
249+
}
250+
251+
// Generate all time slots and fill with zeros where missing
252+
const filledData: Record<string, unknown>[] = [];
253+
const startTime = Math.floor(minTime / effectiveInterval) * effectiveInterval;
254+
255+
for (let t = startTime; t <= maxTime; t += effectiveInterval) {
256+
const existing = existingData.get(t);
257+
if (existing) {
258+
filledData.push(existing);
259+
} else {
260+
// Create a zero-filled data point
261+
const zeroPoint: Record<string, unknown> = {
262+
[xDataKey]: t,
263+
__rawDate: new Date(t),
264+
__granularity: granularity,
265+
__originalX: new Date(t).toISOString(),
266+
};
267+
for (const s of series) {
268+
zeroPoint[s] = 0;
269+
}
270+
filledData.push(zeroPoint);
271+
}
272+
}
273+
274+
return filledData;
275+
}
276+
145277
/**
146278
* "Nice" intervals for time axes - these create human-friendly tick marks
147279
*/
@@ -361,7 +493,7 @@ function transformDataForChart(
361493

362494
// No grouping: use Y columns directly as series
363495
if (!groupByColumn) {
364-
const data = rows
496+
let data = rows
365497
.map((row) => {
366498
const rawDate = tryParseDate(row[xAxisColumn]);
367499
const point: Record<string, unknown> = {
@@ -380,6 +512,21 @@ function transformDataForChart(
380512
// Filter out rows with invalid dates for date-based axes
381513
.filter((point) => !isDateBased || point.__rawDate !== null);
382514

515+
// Fill in gaps with zeros for date-based data
516+
if (isDateBased && timeDomain) {
517+
const timestamps = dateValues.map((d) => d.getTime());
518+
const dataInterval = detectDataInterval(timestamps);
519+
data = fillTimeGaps(
520+
data,
521+
xDataKey,
522+
yAxisColumns,
523+
timeDomain[0],
524+
timeDomain[1],
525+
dataInterval,
526+
granularity
527+
);
528+
}
529+
383530
return { data, series: yAxisColumns, dateValues, isDateBased, xDataKey, timeDomain, timeTicks };
384531
}
385532

@@ -416,7 +563,7 @@ function transformDataForChart(
416563

417564
// Convert to array format
418565
const series = Array.from(groupValues).sort();
419-
const data = Array.from(groupedByX.entries()).map(([xKey, { values, rawDate, originalX }]) => {
566+
let data = Array.from(groupedByX.entries()).map(([xKey, { values, rawDate, originalX }]) => {
420567
const point: Record<string, unknown> = {
421568
[xDataKey]: xKey,
422569
__rawDate: rawDate,
@@ -429,6 +576,21 @@ function transformDataForChart(
429576
return point;
430577
});
431578

579+
// Fill in gaps with zeros for date-based data
580+
if (isDateBased && timeDomain) {
581+
const timestamps = dateValues.map((d) => d.getTime());
582+
const dataInterval = detectDataInterval(timestamps);
583+
data = fillTimeGaps(
584+
data,
585+
xDataKey,
586+
series,
587+
timeDomain[0],
588+
timeDomain[1],
589+
dataInterval,
590+
granularity
591+
);
592+
}
593+
432594
return { data, series, dateValues, isDateBased, xDataKey, timeDomain, timeTicks };
433595
}
434596

0 commit comments

Comments
 (0)