Skip to content
23 changes: 17 additions & 6 deletions apps/web/src/lib/components/charts/BreakdownChart.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@
props.granularity ?? config.defaultGranularity
);

let addressType = $derived(props.addressType ?? 'sa');
const ipVersion = $derived<MaadIpVersion>(props.ipVersion ?? DEFAULT_MAAD_IP_VERSION);
type FilterInputs = {
startDate: string;
Expand All @@ -113,6 +114,7 @@
direction: FlowDirection;
ipVersion?: MaadIpVersion;
measure?: MaadMeasure;
addressSide?: 'source' | 'destination';
};

const filters = $derived<FilterInputs>({
Expand All @@ -126,7 +128,10 @@
: []
: deriveSelectedRouters(props.routers),
direction: props.direction ?? 'all',
...(config.usesMaad ? { ipVersion, measure: props.measure } : {})
...(config.usesMaad ? { ipVersion, measure: props.measure } : {}),
...(props.kind === 'spectrum'
? { addressSide: addressType === 'sa' ? ('source' as const) : ('destination' as const) }
: {})
});
const requestKey = $derived(
JSON.stringify({
Expand Down Expand Up @@ -159,7 +164,6 @@
? settled.error
: null
);
let addressType = $derived(props.addressType ?? 'sa');
const selectedSide = $derived<DimensionSide | null>(
props.kind === 'spectrum'
? addressType
Expand Down Expand Up @@ -221,6 +225,11 @@
const bucketStarts = $derived(
[...new Set(buckets.map((bucket) => bucket.bucketStart))].sort((a, b) => a - b)
);
const bucketLabels = $derived(
new Map(
bucketStarts.map((start) => [start, formatTemporalBucketLabel(start, currentGranularity)])
)
);
const series = $derived.by((): PlotSeries[] => {
if (props.kind === 'spectrum')
return [
Expand All @@ -232,7 +241,7 @@
x: bucket.bucketStart,
y: point.alpha,
f: point.f,
label: formatTemporalBucketLabel(bucket.bucketStart, currentGranularity),
label: bucketLabels.get(bucket.bucketStart),
coverage: bucket.coverage
}))
)
Expand Down Expand Up @@ -264,7 +273,7 @@
return {
x: start,
y: readLineMetric((bucket?.data as LineBucketData | null) ?? null, metric.key),
label: formatTemporalBucketLabel(start, currentGranularity),
label: bucketLabels.get(start),
coverage: getChartBucketCoverage(bucket) ?? {
state: 'unknown',
observedUnits: 0,
Expand All @@ -281,7 +290,8 @@
plotBounds(series, groupByBucketDurationMs(IP_TO_GROUP_BY[currentGranularity]) / 1000)
);
const chartOptions = $derived.by((): PlotOptions => {
const points = series.flatMap((item) => item.data).filter(finitePoint);
const points =
props.kind === 'spectrum' ? series.flatMap((item) => item.data).filter(finitePoint) : [];
let minAlpha = Infinity;
let maxAlpha = -Infinity;
let minF = Infinity;
Expand Down Expand Up @@ -342,7 +352,8 @@
routers: filters.routers.join(','),
direction: filters.direction,
...(filters.ipVersion !== undefined ? { ipVersion: String(filters.ipVersion) } : {}),
...(filters.measure !== undefined ? { measure: filters.measure } : {})
...(filters.measure !== undefined ? { measure: filters.measure } : {}),
...(filters.addressSide ? { addressSide: filters.addressSide } : {})
});
const cacheKey = `${config.endpoint}?${params}`;
await ensureCachedWindow<CachedBreakdownBucket>({
Expand Down
166 changes: 74 additions & 92 deletions apps/web/src/lib/components/charts/ChartPlot.svelte
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
<script lang="ts">
import { SvelteSet, SvelteMap } from 'svelte/reactivity';
import { SvelteSet } from 'svelte/reactivity';
import { untrack } from 'svelte';
import { mountChart } from '@tanstack/charts/dom';
import { defineChart } from '@tanstack/charts/scene';
import type { ChartPoint, ChartRenderContext } from '@tanstack/charts';
import {
Chart,
buildChartDefinition,
finitePoint,
plotTooltip,
Expand All @@ -11,6 +13,9 @@
type PlotSeries
} from './chart-registry';
import { paintLegendSwatch } from './legend-swatch';
import { paintSpectrumCloud } from './spectrum-cloud';
import { renderCoverageSvg } from './coverage-marks';
import { plotObservations, positionedScenePoints } from './chart-observations';
import { createChartContract } from './chart-contract';
import { MIN_DRAG_PIXELS, findNearestValueIndex } from './chart-utils';
import { crosshairStore } from '#lib/stores/crosshair.ts';
Expand All @@ -37,8 +42,8 @@
emptyCopy?: string;
retainEmptySurface?: boolean;
} = $props();
const generatedId = $props.id();
const hidden = new SvelteSet<string>();
let size = $state({ width: 800, height: 320 });
let context: ChartRenderContext<PlotPoint, number, number> | null = null;
let contract: ReturnType<typeof createChartContract> | null = null;
let contractSurface: HTMLElement | SVGElement | null = null;
Expand All @@ -51,25 +56,32 @@
let selection: HTMLDivElement | null = null;
let externalCrosshair: HTMLDivElement | null = null;
const hasData = $derived(series.some((item) => item.data.some(finitePoint)));
const timeline = $derived.by(() => {
const unique = new SvelteMap<number, PlotPoint>();
for (const item of series) {
if (hidden.has(item.label)) continue;
for (const point of item.data) if (finitePoint(point)) unique.set(point.x, point);
}
return [...unique.values()].sort((left, right) => left.x - right.x);
});
const timestamps = $derived(timeline.map((point) => point.x));
const labeledPoints = $derived(new SvelteMap(timeline.map((point) => [point.label, point])));
const definition = $derived(
buildChartDefinition(
series,
{ ...options, compact: options.compact && size.width < 560 },
hidden,
const observationData = $derived(plotObservations(series, hidden));
let observations: ReturnType<typeof plotObservations> | null = null;
const definition = $derived.by(() => {
const currentSeries = series;
const currentOptions = options;
const currentHidden = new Set(hidden);
const formatter = formatTooltip;
const normal = buildChartDefinition(
currentSeries,
{ ...currentOptions, compact: false },
currentHidden,
toggle,
formatTooltip
)
);
formatter
);
return defineChart({
chart: ({ width }) =>
currentOptions.compact && width < 560
? buildChartDefinition(currentSeries, currentOptions, currentHidden, toggle, formatter)
: normal,
focus: normal.focus,
tooltip: normal.tooltip,
motion: false,
svgAnimation: false,
keyboard: true
});
});

function toggle(visible: readonly string[]) {
for (const item of series) {
Expand All @@ -78,37 +90,42 @@
}
}

function attachSize(node: HTMLDivElement) {
const observer = new ResizeObserver(() => {
const width = node.clientWidth;
const height = node.clientHeight;
if (width > 0 && height > 0 && (width !== size.width || height !== size.height))
size = { width, height };
function attachPlot(node: HTMLDivElement) {
const hostOptions = () => ({
definition,
idPrefix: `atlantis-chart-${generatedId.replaceAll(/[^a-zA-Z0-9_-]/g, '')}`,
ariaLabel: name,
renderSvg: options.kind === 'coverage' ? renderCoverageSvg : undefined,
onRender: publish,
onFocusGroupChange: focusGroup,
onSelect: select
});
let host: ReturnType<typeof mountChart<PlotPoint, number, number>> | null = null;
$effect(() => {
const current = hostOptions();
const currentObservations = observationData;
untrack(() => {
observations = currentObservations;
if (host) host.update(current);
else host = mountChart(node, current);
});
});
observer.observe(node);
return () => {
observer.disconnect();
cleanupRender();
contract?.destroy();
host?.destroy();
contract = null;
contractSurface = null;
context = null;
observations = null;
if (chartId && crosshairStore.sourceChartId === chartId) crosshairStore.clearHover();
if (chartId && rangeSelection.selection?.sourceChartId === chartId) rangeSelection.clear();
};
}

function attachLifetime() {
return () => {
cleanupRender();
contract?.destroy();
contract = null;
contractSurface = null;
context = null;
};
}

function publish(next: ChartRenderContext<PlotPoint, number, number>) {
cleanupRender();
paintSpectrumCloud(next, series);
context = next;
const surface = next.surface.element;
if (!(surface instanceof HTMLElement || surface instanceof SVGElement))
Expand All @@ -118,40 +135,22 @@
contract = createChartContract(surface);
contractSurface = surface;
}
const seen = new SvelteSet<PlotPoint>();
const positioned = new SvelteMap<string, ChartPoint<PlotPoint, number, number>[]>();
for (const point of next.scene.points) {
if (
seen.has(point.datum) ||
!finitePoint(point.datum) ||
!Number.isFinite(point.x) ||
!Number.isFinite(point.y)
)
continue;
if (
point.x < next.scene.chart.x - 0.01 ||
point.x > next.scene.chart.x + next.scene.chart.width + 0.01
)
continue;
seen.add(point.datum);
const markId = point.markId.replace(/-(complete|partial|run-\d+)$/, '');
const points = positioned.get(markId) ?? [];
points.push(point);
positioned.set(markId, points);
}
const positioned = positionedScenePoints(next.scene);
const buttons = Array.from(
next.container.querySelectorAll<HTMLButtonElement>('[data-chart-legend-value]')
);
for (const button of buttons) {
const item = series.find((item) => item.label === button.dataset.chartLegendValue);
if (item) paintLegendSwatch(button, item, options.kind);
}

const bounds = surface.getBoundingClientRect();
contract?.render({
name,
kind: options.kind === 'coverage' ? 'line' : (options.kind ?? 'line'),
axes:
options.compact && size.width < 560 ? [] : [options.xTitle, options.yTitle].filter(Boolean),
options.compact && next.scene.width < 560
? []
: [options.xTitle, options.yTitle].filter(Boolean),
series: series.map((item, index) => {
const visible = !hidden.has(item.label);
const points = positioned.get(`series-${index}`) ?? [];
Expand All @@ -173,11 +172,8 @@
const finite = values.filter(
(value): value is number => typeof value === 'number' && Number.isFinite(value)
);
const button = Array.from(
next.container.querySelectorAll<HTMLButtonElement>('[data-chart-legend-value]')
).find((button) => button.dataset.chartLegendValue === item.label);
const button = buttons.find((button) => button.dataset.chartLegendValue === item.label);
const box = button?.getBoundingClientRect();
const bounds = surface.getBoundingClientRect();
const target = points[0];
return {
label: item.label,
Expand Down Expand Up @@ -248,8 +244,11 @@
function atPixel(x: number): PlotPoint | null {
if (!context) return null;
const value = context.scene.scales.x.invert?.(x);
const index = typeof value === 'number' ? findNearestValueIndex(timestamps, value) : null;
return index === null ? null : timeline[index];
const index =
typeof value === 'number'
? findNearestValueIndex(observations?.timestamps ?? [], value)
: null;
return index === null ? null : (observations?.timeline[index] ?? null);
}

function pointerPosition(event: MouseEvent) {
Expand Down Expand Up @@ -341,8 +340,8 @@
function paintSelection() {
if (!selection || !context) return;
const mirrored = rangeSelection.selection;
const from = labeledPoints.get(mirrored?.startLabel);
const to = labeledPoints.get(mirrored?.endLabel);
const from = observations?.labeledPoints.get(mirrored?.startLabel);
const to = observations?.labeledPoints.get(mirrored?.endLabel);
const left = rangeStart ?? (from ? context.scene.scales.x.map(from.x) : null);
const right = rangeEnd ?? (to ? context.scene.scales.x.map(to.x) : null);
selection.hidden = left === null || right === null || Math.abs(right - left) < MIN_DRAG_PIXELS;
Expand All @@ -353,7 +352,7 @@
function paintExternalHover() {
if (!externalCrosshair || !context) return;
const label = chartId ? crosshairStore.getExternalLabel(chartId) : null;
const point = label ? labeledPoints.get(label) : null;
const point = label ? observations?.labeledPoints.get(label) : null;
externalCrosshair.hidden = !point;
if (point)
externalCrosshair.style.cssText = `left:${context.scene.scales.x.map(point.x)}px;top:${context.scene.chart.y}px;height:${context.scene.chart.height}px`;
Expand Down Expand Up @@ -397,7 +396,7 @@
if (!event.shiftKey || !['ArrowLeft', 'ArrowRight'].includes(event.key)) return;
event.preventDefault();
event.stopImmediatePropagation();
const points = timeline;
const points = observations?.timeline ?? [];
const index = Math.max(
0,
points.findIndex((point) => point.x === focused?.x)
Expand All @@ -421,27 +420,10 @@
}
</script>

<div class="relative h-full w-full min-w-0" {@attach attachSize}>
<div class="relative h-full w-full min-w-0">
{#if hasData || retainEmptySurface}
<div class="h-full" {@attach attachLifetime}>
<Chart
{definition}
ariaLabel={name}
height={size.height}
onRender={publish}
onFocusGroupChange={focusGroup}
onSelect={select}
>
{#snippet tooltipBody({ points, defaultBody })}
{#if options.kind === 'scatter'}
<div role="tooltip" class="whitespace-pre-line">
{plotTooltip(points, formatTooltip)}
</div>
{:else}
{@render defaultBody()}
{/if}
{/snippet}
</Chart>
<div class="ts-chart-host h-full">
<div class="ts-chart-surface h-full w-full" {@attach attachPlot}></div>
</div>
{#if !hasData}<p
class="text-muted-foreground pointer-events-none absolute inset-0 flex items-center justify-center px-4 text-center text-sm"
Expand Down
Loading
Loading