Dashboard charts: drag across one to zoom #79

Merged
jcoffey-dev merged 1 commits from feat/chart-zoom into main 2026-10-05 22:08:19 +00:00
7 changed files with 304 additions and 15 deletions

No files matched your search

@@ -7,7 +7,7 @@
import { useEffect, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { Gauge as GaugeIcon, RefreshCw } from 'lucide-react';
import { Gauge as GaugeIcon, RefreshCw, X } from 'lucide-react';
import type { Dashboard } from '../types/schema';
import { cn } from '@/lib/utils';
import { AUTO_REFRESH, useDashboardStore, type AutoRefresh } from '../stores/dashboardStore';
@@ -71,6 +71,8 @@ export function PeriodSwitch({ loading = false }: { loading?: boolean }) {
const { t } = useTranslation();
const period = useDashboardStore((s) => s.period);
const setPreset = useDashboardStore((s) => s.setPreset);
const zoomedFrom = useDashboardStore((s) => s.zoomedFrom);
const resetZoom = useDashboardStore((s) => s.resetZoom);
const every = useDashboardStore((s) => s.autoRefresh);
const setEvery = useDashboardStore((s) => s.setAutoRefresh);
const onRefresh = useDashboardStore((s) => s.bump);
@@ -103,6 +105,21 @@ export function PeriodSwitch({ loading = false }: { loading?: boolean }) {
</button>
))}
</div>
{/* inbuxa: zoomed in from a chart, the window shown, and the way back. */}
{zoomedFrom && period.kind === 'custom' && (
<button
type="button"
onClick={resetZoom}
title={t('cc.zoomReset', 'Back to {{period}}', {
period: zoomedFrom.kind === 'preset' ? zoomedFrom.preset.toUpperCase() : '',
})}
className="inline-flex items-center gap-1.5 rounded-lg border border-primary/50 bg-primary/10 px-2.5 py-1 font-mono text-[11px] font-semibold tracking-wider text-foreground transition-colors hover:bg-primary/20"
>
<span className="uppercase text-muted-foreground">{t('cc.zoomed', 'Zoom')}</span>
{formatZoom(period.from, period.to)}
<X className="h-3 w-3 text-muted-foreground" aria-hidden />
</button>
)}
<div className="flex items-center rounded-lg border bg-background/60">
<button
type="button"
@@ -209,3 +226,12 @@ export function DashNav({
</nav>
);
}
/** inbuxa: a zoomed window, short: "5 Oct 14:00 – 17:00", or both dates when it spans days. */
function formatZoom(from: Date, to: Date): string {
const day = (d: Date) => d.toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
const time = (d: Date) => d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
return day(from) === day(to)
? `${day(from)} ${time(from)} – ${time(to)}`
: `${day(from)} ${time(from)} – ${day(to)} ${time(to)}`;
}
@@ -29,7 +29,10 @@ import {
formatValue,
getBucketCount,
seriesBucketValue,
sliceAt,
zoomWindow,
} from '../helpers';
import { useDashboardStore } from '../stores/dashboardStore';
import { LATENCIES, summarize } from '../commandCenter';
import { hrefFor, linkForMetrics } from '../links';
import { usePermissions } from '@/hooks/usePermissions';
@@ -74,11 +77,16 @@ export function TrendChart({ chart, samples, window: { from, to }, period, class
const format = chart.valueFormat ?? 'number';
const timing = format === 'duration';
const buckets = getBucketCount(period);
const zoomTo = useDashboardStore((s) => s.zoomTo);
// Dragging across the chart: the slices it started and is now over.
const [drag, setDrag] = useState<{ a: number; b: number } | null>(null);
const canZoom = zoomWindow(from, to, buckets, 0, 1) !== null;
const data = useMemo(() => {
const slices = bucketize(samples, from, to, buckets);
return bucketTimestamps(from, to, buckets).map((ts, i) => {
const point: Record<string, number | string | null> = { tick: formatTimeTick(ts, period) };
// Charted by slice, so two slices that print alike ("14:00" on two days) stay apart.
const point: Record<string, number | string | null> = { i, tick: formatTimeTick(ts, period) };
for (const s of chart.series) point[s.label] = seriesBucketValue(s, slices[i]) ?? (timing ? null : 0);
return point;
});
@@ -90,10 +98,10 @@ export function TrendChart({ chart, samples, window: { from, to }, period, class
const peak = useMemo(() => {
if (timing || chart.kind === 'bar' || chart.series.length === 0) return null;
const keys = chart.stacked ? chart.series.map((s) => s.label) : [chart.series[0].label];
let best: { tick: string; v: number } | null = null;
let best: { i: number; v: number } | null = null;
for (const p of data) {
const v = keys.reduce((sum, k) => sum + Number(p[k] ?? 0), 0);
if (v > 0 && (!best || v > best.v)) best = { tick: String(p.tick), v };
if (v > 0 && (!best || v > best.v)) best = { i: Number(p.i), v };
}
return best;
}, [data, chart.series, chart.stacked, chart.kind, timing]);
@@ -105,7 +113,51 @@ export function TrendChart({ chart, samples, window: { from, to }, period, class
const href = link && canViewObject(link.viewName) ? hrefFor(link) : null;
const fmt = (v: number) => formatValue(v, format);
const common = { data, width, height: HEIGHT, margin: { top: 16, right: 24, left: 0, bottom: 0 } };
const tickAt = (i: unknown) => String(data[Number(i)]?.tick ?? '');
// Dragging is read off the pointer against the plot area (the chart's grid),
// not off recharts' hover state, which runs a frame behind: a quick sweep
// would end on the slice it started from.
const band = chart.kind === 'bar';
const sliceAtPointer = (clientX: number) => {
const plot = ref.current?.querySelector('.recharts-cartesian-grid')?.getBoundingClientRect();
return plot ? sliceAt(clientX, plot.left, plot.right, buckets, band) : null;
};
const dragRef = useRef<{ a: number; b: number } | null>(null);
const setDragBoth = (d: { a: number; b: number } | null) => {
dragRef.current = d;
setDrag(d);
};
const zoomHandlers = canZoom
? {
onMouseDown: (e: React.MouseEvent) => {
if (e.button !== 0) return;
const i = sliceAtPointer(e.clientX);
if (i !== null) setDragBoth({ a: i, b: i });
},
onMouseMove: (e: React.MouseEvent) => {
const d = dragRef.current;
const i = d ? sliceAtPointer(e.clientX) : null;
if (d && i !== null && i !== d.b) setDragBoth({ a: d.a, b: i });
},
onMouseUp: (e: React.MouseEvent) => {
const d = dragRef.current;
const end = d ? (sliceAtPointer(e.clientX) ?? d.b) : null;
// A click isn't a drag: only a sweep over two slices or more zooms.
if (d && end !== null && d.a !== end) {
const w = zoomWindow(from, to, buckets, d.a, end);
if (w) zoomTo(w.from, w.to);
}
setDragBoth(null);
},
onMouseLeave: () => setDragBoth(null),
}
: {};
const common = {
data,
width,
height: HEIGHT,
margin: { top: 16, right: 24, left: 0, bottom: 0 },
};
const chrome = (
<>
<defs>
@@ -117,7 +169,7 @@ export function TrendChart({ chart, samples, window: { from, to }, period, class
))}
</defs>
<CartesianGrid vertical={false} stroke="var(--border)" />
<XAxis dataKey="tick" tick={AXIS} tickLine={false} axisLine={false} minTickGap={28} />
<XAxis dataKey="i" tick={AXIS} tickLine={false} axisLine={false} minTickGap={28} tickFormatter={tickAt} />
<YAxis tick={AXIS} tickLine={false} axisLine={false} width={56} tickFormatter={fmt} allowDecimals={timing} />
{zones && (
<>
@@ -142,7 +194,7 @@ export function TrendChart({ chart, samples, window: { from, to }, period, class
content={({ active, payload, label }) =>
active && payload?.length ? (
<div className="rounded-lg border bg-popover px-3 py-2 font-mono text-xs shadow-md">
<div className="mb-1 text-muted-foreground">{label}</div>
<div className="mb-1 text-muted-foreground">{tickAt(label)}</div>
{payload.map((p) => (
<div key={String(p.dataKey)} className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full" style={{ background: p.color }} />
@@ -156,9 +208,19 @@ export function TrendChart({ chart, samples, window: { from, to }, period, class
) : null
}
/>
{drag && drag.a !== drag.b && (
<ReferenceArea
x1={Math.min(drag.a, drag.b)}
x2={Math.max(drag.a, drag.b)}
fill="var(--primary)"
fillOpacity={0.12}
stroke="var(--primary)"
strokeOpacity={0.5}
/>
)}
{peak && (
<ReferenceDot
x={peak.tick}
x={peak.i}
y={peak.v}
r={4}
fill="var(--card)"
@@ -245,9 +307,20 @@ export function TrendChart({ chart, samples, window: { from, to }, period, class
{timing && <span className="text-[10px] uppercase text-muted-foreground">{t('cc.avg', 'avg')}</span>}
</span>
))}
{canZoom && (
<span className="ml-auto self-center font-mono text-[10px] uppercase tracking-wider text-muted-foreground/70">
{t('cc.dragToZoom', 'Drag to zoom')}
</span>
)}
{chart.description && <span className="basis-full text-xs text-muted-foreground">{chart.description}</span>}
</div>
<div ref={ref} style={{ height: HEIGHT }}>
<div
ref={ref}
// A press to drag focuses the chart; keep the ring for the keyboard only.
className="[&_.recharts-surface:focus:not(:focus-visible)]:outline-none"
style={{ height: HEIGHT, ...(canZoom ? { cursor: 'crosshair', userSelect: 'none' } : {}) }}
{...zoomHandlers}
>
{width > 0 && (
<Kind {...common}>
{chrome}
+54 -2
View File
@@ -9,7 +9,14 @@
import type { Metric, MetricId, Period } from './types/metrics';
import type { Card, Series, MetricFormat, Aggregate } from './types/schema';
import { PRESET_MS, BUCKET_CONFIG, CUSTOM_BUCKET_COUNT, SPARKLINE_BUCKET_COUNT } from './types/metrics';
import {
PRESET_MS,
BUCKET_CONFIG,
CUSTOM_BUCKET_COUNT,
HOUR_MS,
MIN_ZOOM_MS,
SPARKLINE_BUCKET_COUNT,
} from './types/metrics';
export function metricToScalar(m: Metric): number {
if (m['@type'] === 'Histogram') {
@@ -82,7 +89,11 @@ export function periodKey(p: Period): string {
}
export function getBucketCount(p: Period): number {
if (p.kind === 'custom') return CUSTOM_BUCKET_COUNT;
if (p.kind === 'custom') {
// inbuxa: a slice per hour of the window, as history is sampled hourly.
const hours = Math.round((p.to.getTime() - p.from.getTime()) / HOUR_MS);
return Math.min(CUSTOM_BUCKET_COUNT, Math.max(MIN_ZOOM_MS / HOUR_MS, hours));
}
return BUCKET_CONFIG[p.preset];
}
@@ -291,3 +302,44 @@ export function collectLiveMetricIds(cards: Card[] | undefined): Set<MetricId> {
}
return ids;
}
/**
* inbuxa: the window a drag across slices `a` to `b` of a chart zooms to: from
* the start of the first slice to the end of the last. A window under three
* hours widens around its middle, staying inside the chart's own. Null when
* the chart's window is already that narrow, so there's nothing to zoom into.
*/
export function zoomWindow(
from: Date,
to: Date,
buckets: number,
a: number,
b: number,
): { from: Date; to: Date } | null {
const start = from.getTime();
const end = to.getTime();
if (end - start <= MIN_ZOOM_MS || buckets <= 0) return null;
const width = (end - start) / buckets;
const lo = Math.max(0, Math.min(a, b));
const hi = Math.min(buckets - 1, Math.max(a, b));
let zFrom = start + lo * width;
let zTo = start + (hi + 1) * width;
if (zTo - zFrom < MIN_ZOOM_MS) {
const mid = (zFrom + zTo) / 2;
zFrom = Math.max(start, Math.min(end - MIN_ZOOM_MS, mid - MIN_ZOOM_MS / 2));
zTo = zFrom + MIN_ZOOM_MS;
}
return { from: new Date(Math.round(zFrom)), to: new Date(Math.round(zTo)) };
}
/**
* inbuxa: which of `buckets` slices a pointer at `x` is over, on a plot from
* `left` to `right`. Lines and areas put the slices on points, first at the
* left edge and last at the right; bars put each in a band of its own.
*/
export function sliceAt(x: number, left: number, right: number, buckets: number, band: boolean): number | null {
if (buckets <= 0 || right <= left) return null;
const f = Math.min(1, Math.max(0, (x - left) / (right - left)));
const i = band ? Math.floor(f * buckets) : Math.round(f * (buckets - 1));
return Math.min(buckets - 1, i);
}
@@ -18,6 +18,14 @@ interface DashboardState {
period: Period;
setPeriod: (period: Period) => void;
setPreset: (preset: PresetKey) => void;
/**
* inbuxa: the period a chart was zoomed from, while zoomed. Zooming makes the
* period a custom window; only this one is remembered, so a reload is back
* at the preset rather than in an old window.
*/
zoomedFrom: Period | null;
zoomTo: (from: Date, to: Date) => void;
resetZoom: () => void;
/** INBUXA: minutes between automatic refreshes of the dashboards; 0 is off. */
autoRefresh: AutoRefresh;
setAutoRefresh: (minutes: AutoRefresh) => void;
@@ -31,8 +39,12 @@ export const useDashboardStore = create<DashboardState>()(
(set) => ({
period: { kind: 'preset', preset: '24h' } as Period,
setPeriod: (period) => set({ period }),
setPreset: (preset) => set({ period: { kind: 'preset', preset } }),
setPeriod: (period) => set({ period, zoomedFrom: null }),
setPreset: (preset) => set({ period: { kind: 'preset', preset }, zoomedFrom: null }),
zoomedFrom: null,
zoomTo: (from, to) =>
set((s) => ({ period: { kind: 'custom', from, to }, zoomedFrom: s.zoomedFrom ?? s.period })),
resetZoom: () => set((s) => (s.zoomedFrom ? { period: s.zoomedFrom, zoomedFrom: null } : {})),
autoRefresh: 5,
setAutoRefresh: (autoRefresh) => set({ autoRefresh }),
tick: 0,
@@ -40,7 +52,7 @@ export const useDashboardStore = create<DashboardState>()(
}),
{
name: 'dashboard.period',
partialize: (state) => ({ period: state.period, autoRefresh: state.autoRefresh }),
partialize: (state) => ({ period: state.zoomedFrom ?? state.period, autoRefresh: state.autoRefresh }),
},
),
);
+4
View File
@@ -49,4 +49,8 @@ export const BUCKET_CONFIG: Record<PresetKey, number> = {
};
export const CUSTOM_BUCKET_COUNT = 60;
// inbuxa: history is sampled hourly unless set otherwise (`metricsCollectionInterval`),
// so a zoomed window gets a slice per hour, 3 to 60 of them, and is never under 3 hours.
export const HOUR_MS = 3_600_000;
export const MIN_ZOOM_MS = 3 * HOUR_MS;
export const SPARKLINE_BUCKET_COUNT = 20;
+119
View File
@@ -0,0 +1,119 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { createJSONStorage } from 'zustand/middleware';
import { getBucketCount, sliceAt, zoomWindow } from './helpers';
import { useDashboardStore } from './stores/dashboardStore';
const HOUR = 3_600_000;
const FROM = new Date('2026-10-05T00:00:00Z');
const at = (h: number) => new Date(FROM.getTime() + h * HOUR);
describe('zoomWindow', () => {
it('runs from the start of the first slice to the end of the last, either way round', () => {
// 24 h in 48 slices of 30 min: slices 20 to 31 are 10:00 to 16:00.
expect(zoomWindow(FROM, at(24), 48, 20, 31)).toEqual({ from: at(10), to: at(16) });
expect(zoomWindow(FROM, at(24), 48, 31, 20)).toEqual({ from: at(10), to: at(16) });
});
it('widens a window under three hours around its middle', () => {
// Slices 20 and 21: 10:00 to 11:00, widened to 09:00 to 12:00.
expect(zoomWindow(FROM, at(24), 48, 20, 21)).toEqual({ from: at(9), to: at(12) });
});
it('keeps a widened window inside the chart', () => {
expect(zoomWindow(FROM, at(24), 48, 0, 1)).toEqual({ from: at(0), to: at(3) });
expect(zoomWindow(FROM, at(24), 48, 46, 47)).toEqual({ from: at(21), to: at(24) });
});
it('has nothing to zoom into once the chart is three hours or less', () => {
expect(zoomWindow(FROM, at(3), 3, 0, 2)).toBeNull();
});
});
describe('sliceAt', () => {
// A plot from x=100 to x=500, 5 slices.
it('puts line and area slices on points, edge to edge', () => {
expect(sliceAt(100, 100, 500, 5, false)).toBe(0);
expect(sliceAt(300, 100, 500, 5, false)).toBe(2);
expect(sliceAt(500, 100, 500, 5, false)).toBe(4);
expect(sliceAt(160, 100, 500, 5, false)).toBe(1);
});
it('puts bar slices in bands', () => {
expect(sliceAt(100, 100, 500, 5, true)).toBe(0);
expect(sliceAt(179, 100, 500, 5, true)).toBe(0);
expect(sliceAt(181, 100, 500, 5, true)).toBe(1);
expect(sliceAt(500, 100, 500, 5, true)).toBe(4);
});
it('holds a pointer past either edge to the end slice', () => {
expect(sliceAt(20, 100, 500, 5, false)).toBe(0);
expect(sliceAt(900, 100, 500, 5, true)).toBe(4);
});
it('has no slice on an empty plot', () => {
expect(sliceAt(300, 100, 100, 5, false)).toBeNull();
expect(sliceAt(300, 100, 500, 0, false)).toBeNull();
});
});
describe('getBucketCount for a zoomed window', () => {
const custom = (h: number) => ({ kind: 'custom' as const, from: FROM, to: at(h) });
it('gives a slice per hour', () => {
expect(getBucketCount(custom(6))).toBe(6);
expect(getBucketCount(custom(40))).toBe(40);
});
it('keeps between 3 and 60 slices', () => {
expect(getBucketCount(custom(2))).toBe(3);
expect(getBucketCount(custom(24 * 20))).toBe(60);
});
});
describe('zooming the dashboard period', () => {
// In memory: Node's own `localStorage` global, empty without a storage file, hides happy-dom's.
const saved = new Map<string, string>();
beforeAll(() =>
useDashboardStore.persist.setOptions({
storage: createJSONStorage(() => ({
getItem: (k) => saved.get(k) ?? null,
setItem: (k, v) => void saved.set(k, v),
removeItem: (k) => void saved.delete(k),
})),
}),
);
beforeEach(() => useDashboardStore.getState().setPreset('7d'));
it('remembers the preset it zoomed from, and goes back to it', () => {
useDashboardStore.getState().zoomTo(at(10), at(16));
expect(useDashboardStore.getState().period).toEqual({ kind: 'custom', from: at(10), to: at(16) });
expect(useDashboardStore.getState().zoomedFrom).toEqual({ kind: 'preset', preset: '7d' });
// Zooming again from inside a zoom still goes back to the preset.
useDashboardStore.getState().zoomTo(at(11), at(14));
expect(useDashboardStore.getState().zoomedFrom).toEqual({ kind: 'preset', preset: '7d' });
useDashboardStore.getState().resetZoom();
expect(useDashboardStore.getState().period).toEqual({ kind: 'preset', preset: '7d' });
expect(useDashboardStore.getState().zoomedFrom).toBeNull();
});
it('ends the zoom when a preset is picked', () => {
useDashboardStore.getState().zoomTo(at(10), at(16));
useDashboardStore.getState().setPreset('30d');
expect(useDashboardStore.getState().period).toEqual({ kind: 'preset', preset: '30d' });
expect(useDashboardStore.getState().zoomedFrom).toBeNull();
});
it('saves the preset, not the zoomed window', () => {
useDashboardStore.getState().zoomTo(at(10), at(16));
const stored = JSON.parse(saved.get('dashboard.period') ?? '{}');
expect(stored.state.period).toEqual({ kind: 'preset', preset: '7d' });
});
});
+4 -1
View File
@@ -1951,7 +1951,10 @@
},
"now": "now",
"avgZone": "average · {{zone}}",
"average": "average"
"average": "average",
"zoomReset": "Back to {{period}}",
"zoomed": "Zoom",
"dragToZoom": "Drag to zoom"
},
"commands": {
"actionHint": "Server action",