Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
841 changes: 291 additions & 550 deletions apps/web/src/lib/components/charts/BreakdownChart.svelte

Large diffs are not rendered by default.

136 changes: 41 additions & 95 deletions apps/web/src/lib/components/charts/SpectrumChart.svelte
Original file line number Diff line number Diff line change
@@ -1,47 +1,18 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { ChartConfiguration } from 'chart.js';
import { finiteSpectrumPoints, paddedSpectrumBounds } from './spectrum-points';
import { Chart } from './annotation-chart-registry';
import type { SpectrumData } from '#lib/types/types.ts';
import { theme } from '#lib/stores/theme.svelte.ts';

let { data }: { data: SpectrumData } = $props();
let chartCanvas: HTMLCanvasElement;
let chart: Chart | null = null;
const points = $derived(finiteSpectrumPoints(data.spectrum));

onMount(() => {
return () => {
destroyChart();
};
});

$effect(() => {
if (!chartCanvas?.parentElement) {
return;
}

const observer = new ResizeObserver(() => {
chart?.resize();
});
observer.observe(chartCanvas.parentElement);

return () => {
observer.disconnect();
};
});

$effect(() => {
function attachChart(canvas: HTMLCanvasElement) {
void theme.dark;
if (!chartCanvas) {
return;
}

if (data?.spectrum?.length > 0) {
updateChart();
return;
}

destroyChart();
});
const chart = new Chart(canvas, buildConfig());
return () => chart.destroy();
}

function getChartColors() {
const style = getComputedStyle(document.documentElement);
Expand All @@ -54,21 +25,14 @@
};
}

function destroyChart() {
if (chart) {
chart.destroy();
chart = null;
}
}

function updateChart() {
const points = data.spectrum;
function buildConfig(): ChartConfiguration<'line'> {
const { textColor, gridColor, tooltipBackgroundColor, tooltipTextColor, tooltipBorderColor } =
getChartColors();

const alphaValues = points.map((p) => p.alpha);
const minAlpha = Math.min(...alphaValues);
const maxAlpha = Math.max(...alphaValues);
const bounds = paddedSpectrumBounds(minAlpha, maxAlpha);

const chartData = {
datasets: [
Expand All @@ -85,31 +49,11 @@
pointBorderWidth: 1,
fill: false,
tension: 0.3
},
{
label: 'y = x (reference)',
data: [
{
x: minAlpha - 0.02 * (maxAlpha - minAlpha),
y: minAlpha - 0.02 * (maxAlpha - minAlpha)
},
{
x: maxAlpha + 0.02 * (maxAlpha - minAlpha),
y: maxAlpha + 0.02 * (maxAlpha - minAlpha)
}
],
borderColor: 'rgba(128, 128, 128, 0.5)',
borderWidth: 1,
borderDash: [5, 5],
pointRadius: 0,
pointHoverRadius: 0,
fill: false,
tension: 0
}
]
};

const config = {
return {
type: 'line' as const,
data: chartData,
options: {
Expand All @@ -119,8 +63,8 @@
scales: {
x: {
type: 'linear' as const,
min: minAlpha - 0.02 * (maxAlpha - minAlpha),
max: maxAlpha + 0.02 * (maxAlpha - minAlpha),
min: bounds.min,
max: bounds.max,
title: {
display: true,
text: 'alpha',
Expand Down Expand Up @@ -148,50 +92,43 @@
labels: { color: textColor }
},
tooltip: {
mode: 'index' as const,
mode: 'nearest' as const,
intersect: false,
backgroundColor: tooltipBackgroundColor,
titleColor: tooltipTextColor,
bodyColor: tooltipTextColor,
borderColor: tooltipBorderColor,
borderWidth: 1,
callbacks: {
title: (items: { parsed: { x: number } }[]) =>
`alpha = ${items[0]?.parsed?.x?.toFixed(6)}`,
label: (item: {
dataset: { label: string };
parsed: { y: number };
dataIndex: number;
}) => {
const value = item.parsed.y.toFixed(6);
title: (items) => `alpha = ${items[0]?.parsed?.x?.toFixed(6)}`,
label: (item) => {
const value = item.parsed.y?.toFixed(6);
return `${item.dataset.label}: ${value}`;
}
}
},
verticalCrosshair: {
enabled: true,
tooltip: {
enabled: false
annotation: {
annotations: {
reference: {
type: 'line',
xMin: bounds.min,
xMax: bounds.max,
yMin: bounds.min,
yMax: bounds.max,
borderColor: 'rgba(128, 128, 128, 0.5)',
borderWidth: 1,
borderDash: [5, 5]
}
}
}
},
verticalCrosshair: false
},
interaction: {
mode: 'index' as const,
mode: 'nearest' as const,
intersect: false
}
}
};

if (!chart) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
chart = new Chart(chartCanvas, config as any);
return;
}

chart.data = chartData;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
chart.options = config.options as any;
chart.update('none');
}
</script>

Expand All @@ -207,6 +144,15 @@
{/if}
</div>
<div class="relative h-72 w-full min-w-0 sm:h-96">
<canvas bind:this={chartCanvas}></canvas>
{#if points.length > 0}
<canvas {@attach attachChart} aria-label="Multifractal spectrum chart"></canvas>
{:else}
<p class="text-muted-foreground flex h-full items-center justify-center">
No finite spectrum data for this selection.
</p>
{/if}
</div>
{#if points.length > 0}
<p class="text-muted-foreground mt-1 text-xs">Dashed line: f(alpha) = alpha.</p>
{/if}
</div>
67 changes: 41 additions & 26 deletions apps/web/src/lib/components/charts/chart-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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);
}
Expand All @@ -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);
}
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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<number[]>((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<number[]>((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];
Expand Down Expand Up @@ -271,7 +282,8 @@ export type TemporalDataBounds = {
export function findTemporalDataBounds<T>(
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;
Expand All @@ -284,7 +296,10 @@ export function findTemporalDataBounds<T>(
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 {
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/lib/components/charts/crosshair-plugin.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -39,6 +39,12 @@ interface CrosshairOptions {
};
}

declare module 'chart.js' {
interface PluginOptionsByType<TType extends ChartType> {
verticalCrosshair?: TType extends 'line' | 'bar' | 'scatter' ? CrosshairOptions | false : never;
}
}

// Store mouse state for each chart instance
const chartStates = new WeakMap<Chart, CrosshairState>();

Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/lib/components/charts/ip-time-axis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/lib/components/charts/spectrum-points.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading
Loading