diff --git a/apps/web/src/lib/components/charts/BreakdownChart.svelte b/apps/web/src/lib/components/charts/BreakdownChart.svelte index 6ac2c5f..5d58f3e 100644 --- a/apps/web/src/lib/components/charts/BreakdownChart.svelte +++ b/apps/web/src/lib/components/charts/BreakdownChart.svelte @@ -1,5 +1,7 @@ @@ -1627,22 +1367,23 @@ {/if} {/snippet} - + {#snippet overlay()} {#if props.kind === 'spectrum'} - {#if !rangeDrag.isDraggingRange && activeCrosshairX !== null} + {#if !rangeDrag.isDraggingRange && hover.activeCrosshairX !== null}
{/if} - {#if !rangeDrag.isDraggingRange && localHoverX !== null && showLocalTooltip && localHoverLabel} + {#if !rangeDrag.isDraggingRange && hover.localHoverX !== null && hover.showLocalTooltip && hover.localHoverLabel} {/if} {/if} diff --git a/apps/web/src/lib/components/charts/SpectrumChart.svelte b/apps/web/src/lib/components/charts/SpectrumChart.svelte index 7c18a72..8a5197b 100644 --- a/apps/web/src/lib/components/charts/SpectrumChart.svelte +++ b/apps/web/src/lib/components/charts/SpectrumChart.svelte @@ -1,47 +1,18 @@ @@ -207,6 +144,15 @@ {/if}
- + {#if points.length > 0} + + {:else} +

+ No finite spectrum data for this selection. +

+ {/if}
+ {#if points.length > 0} +

Dashed line: f(alpha) = alpha.

+ {/if} diff --git a/apps/web/src/lib/components/charts/chart-utils.ts b/apps/web/src/lib/components/charts/chart-utils.ts index 2806107..855a910 100644 --- a/apps/web/src/lib/components/charts/chart-utils.ts +++ b/apps/web/src/lib/components/charts/chart-utils.ts @@ -64,13 +64,18 @@ export function clampToChartX(chart: Chart | null, x: number): number { return Math.max(area.left, Math.min(area.right, x)); } -export function indexFromPixelX(chart: Chart | null, pixelX: number): number | null { +export function indexFromPixelX( + chart: Chart | null, + pixelX: number, + bucketStarts?: readonly number[] +): number | null { if (!chart?.data.labels) return null; const labels = chart.data.labels; if (!labels || labels.length === 0) return null; const value = chart.scales.x.getValueForPixel(pixelX); if (typeof value !== 'number' || Number.isNaN(value)) return null; if ((chart.scales.x.options as { type?: string }).type === 'linear') { + if (bucketStarts) return findNearestValueIndex(bucketStarts, value); const points = chart.data.datasets[0]?.data ?? []; let closestIndex: number | null = null; let closestDistance = Infinity; @@ -115,7 +120,8 @@ export function beginRangeDrag( event: MouseEvent, chartCanvas: HTMLCanvasElement | null, chart: Chart | null, - onPreviewRange?: (startIndex: number, endIndex: number) => void + onPreviewRange?: (startIndex: number, endIndex: number) => void, + bucketStarts?: readonly number[] ): void { if (event.button !== 0 || !chartCanvas) return; const rect = chartCanvas.getBoundingClientRect(); @@ -129,7 +135,7 @@ export function beginRangeDrag( state.dragCurrentX = state.dragStartX; state.selectionTop = area.top; state.selectionHeight = area.bottom - area.top; - const startIndex = indexFromPixelX(chart, state.dragStartX); + const startIndex = indexFromPixelX(chart, state.dragStartX, bucketStarts); if (startIndex !== null) { onPreviewRange?.(startIndex, startIndex); } @@ -140,14 +146,15 @@ export function updateRangeDrag( event: MouseEvent, chartCanvas: HTMLCanvasElement | null, chart: Chart | null, - onPreviewRange?: (startIndex: number, endIndex: number) => void + onPreviewRange?: (startIndex: number, endIndex: number) => void, + bucketStarts?: readonly number[] ): void { if (!state.isDraggingRange || !chartCanvas) return; const rect = chartCanvas.getBoundingClientRect(); const x = event.clientX - rect.left; state.dragCurrentX = clampToChartX(chart, x); - const startIndex = indexFromPixelX(chart, state.dragStartX); - const endIndex = indexFromPixelX(chart, state.dragCurrentX); + const startIndex = indexFromPixelX(chart, state.dragStartX, bucketStarts); + const endIndex = indexFromPixelX(chart, state.dragCurrentX, bucketStarts); if (startIndex !== null && endIndex !== null) { onPreviewRange?.(startIndex, endIndex); } @@ -156,13 +163,14 @@ export function updateRangeDrag( export function endRangeDrag( state: RangeDragState, chart: Chart | null, - onCommitRange: (startIndex: number, endIndex: number) => void + onCommitRange: (startIndex: number, endIndex: number) => void, + bucketStarts?: readonly number[] ): void { if (!state.isDraggingRange) return; const wasDrag = Math.abs(state.dragCurrentX - state.dragStartX) >= MIN_DRAG_PIXELS; if (wasDrag) { - const startIndex = indexFromPixelX(chart, state.dragStartX); - const endIndex = indexFromPixelX(chart, state.dragCurrentX); + const startIndex = indexFromPixelX(chart, state.dragStartX, bucketStarts); + const endIndex = indexFromPixelX(chart, state.dragCurrentX, bucketStarts); if (startIndex !== null && endIndex !== null) { onCommitRange(startIndex, endIndex); state.suppressNextClick = true; @@ -174,7 +182,8 @@ export function endRangeDrag( export function buildMirroredSelectionStyle( chart: Chart | null, mirroredRange: RangeSelectionState | null, - sourceChartId: string + sourceChartId: string, + bucketStarts?: readonly number[] ): string | null { if (!mirroredRange || mirroredRange.sourceChartId === sourceChartId || !chart?.data.labels) return null; @@ -187,20 +196,22 @@ export function buildMirroredSelectionStyle( const from = Math.min(startIndex, endIndex); const to = Math.max(startIndex, endIndex); const firstDataset = chart.data.datasets[0]?.data ?? []; - const xValues = firstDataset.reduce((values, point) => { - if ( - typeof point === 'object' && - point !== null && - !Array.isArray(point) && - 'x' in point && - typeof point.x === 'number' && - Number.isFinite(point.x) && - !values.includes(point.x) - ) { - values.push(point.x); - } - return values; - }, []); + const xValues = + bucketStarts ?? + firstDataset.reduce((values, point) => { + if ( + typeof point === 'object' && + point !== null && + !Array.isArray(point) && + 'x' in point && + typeof point.x === 'number' && + Number.isFinite(point.x) && + !values.includes(point.x) + ) { + values.push(point.x); + } + return values; + }, []); const xValueAt = (index: number): number => { if ((chart.scales.x.options as { type?: string }).type === 'linear') { const uniqueX = xValues[index]; @@ -271,7 +282,8 @@ export type TemporalDataBounds = { export function findTemporalDataBounds( items: readonly T[], getTimestamp: (item: T) => number, - hasData: (item: T) => boolean + hasData: (item: T) => boolean, + singleBucketDuration = 0 ): TemporalDataBounds | null { let min = Infinity; let max = -Infinity; @@ -284,7 +296,10 @@ export function findTemporalDataBounds( max = Math.max(max, timestamp); } - return Number.isFinite(min) && Number.isFinite(max) ? { min, max } : null; + if (!Number.isFinite(min) || !Number.isFinite(max)) return null; + return min === max + ? { min: min - singleBucketDuration / 2, max: max + singleBucketDuration / 2 } + : { min, max }; } export function getXAxisTitle(groupBy: GroupByOption): string { diff --git a/apps/web/src/lib/components/charts/crosshair-plugin.ts b/apps/web/src/lib/components/charts/crosshair-plugin.ts index 0e29a8e..8815504 100644 --- a/apps/web/src/lib/components/charts/crosshair-plugin.ts +++ b/apps/web/src/lib/components/charts/crosshair-plugin.ts @@ -1,4 +1,4 @@ -import type { Chart, ChartArea, Plugin, ChartEvent } from 'chart.js'; +import type { Chart, ChartArea, Plugin, ChartEvent, ChartType } from 'chart.js'; import { cancelDrawFrame, requestDrawFrame } from '#lib/utils/animation-frame.ts'; import { findNearestValueIndex } from './chart-utils'; @@ -39,6 +39,12 @@ interface CrosshairOptions { }; } +declare module 'chart.js' { + interface PluginOptionsByType { + verticalCrosshair?: TType extends 'line' | 'bar' | 'scatter' ? CrosshairOptions | false : never; + } +} + // Store mouse state for each chart instance const chartStates = new WeakMap(); diff --git a/apps/web/src/lib/components/charts/ip-time-axis.ts b/apps/web/src/lib/components/charts/ip-time-axis.ts index 1155433..20620d9 100644 --- a/apps/web/src/lib/components/charts/ip-time-axis.ts +++ b/apps/web/src/lib/components/charts/ip-time-axis.ts @@ -76,10 +76,10 @@ export function shouldHighlightIpGranularityGrid( return index === 0; } -export function placeTicksOnBucketStarts(bucketStarts: number[]) { +export function placeTicksOnBucketStarts(bucketStarts: number[], minimumTickCount = 2) { return (axis: { min: number; max: number; ticks: Array<{ value: number }> }) => { const visibleStarts = bucketStarts.filter((value) => value >= axis.min && value <= axis.max); - if (visibleStarts.length < 2) { + if (visibleStarts.length < minimumTickCount) { return; } axis.ticks = visibleStarts.map((value) => ({ value })); diff --git a/apps/web/src/lib/components/charts/spectrum-points.ts b/apps/web/src/lib/components/charts/spectrum-points.ts new file mode 100644 index 0000000..72bacae --- /dev/null +++ b/apps/web/src/lib/components/charts/spectrum-points.ts @@ -0,0 +1,10 @@ +import type { SpectrumPoint } from '#lib/types/types.ts'; + +export function finiteSpectrumPoints(points: SpectrumPoint[]): SpectrumPoint[] { + return points.filter((point) => Number.isFinite(point.alpha) && Number.isFinite(point.f)); +} + +export function paddedSpectrumBounds(min: number, max: number) { + const padding = Math.max((max - min) * 0.05, Math.max(Math.abs(min), Math.abs(max), 1) * 0.01); + return { min: min - padding, max: max + padding }; +} diff --git a/apps/web/src/lib/components/netflow/DatasetDashboardPage.svelte b/apps/web/src/lib/components/netflow/DatasetDashboardPage.svelte index 5126e99..92c64f1 100644 --- a/apps/web/src/lib/components/netflow/DatasetDashboardPage.svelte +++ b/apps/web/src/lib/components/netflow/DatasetDashboardPage.svelte @@ -1,7 +1,7 @@