diff --git a/src/app/page.tsx b/src/app/page.tsx index c0675ed..5be092b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2003,7 +2003,7 @@ export default function Home() { />
-

Area fill + compact tooltip

+

Area fill + fadeLeft

@@ -2058,6 +2059,7 @@ export default function Home() { height={200} grid tooltip + fadeLeft={60} referenceLines={[ { value: 90, label: 'Target' }, { value: 75, label: 'Minimum' }, @@ -2290,6 +2292,7 @@ export default function Home() { status: (i === 12 ? 'down' : i === 34 ? 'degraded' : i === 67 ? 'down' : i === 45 ? 'degraded' : 'up') as 'up' | 'down' | 'degraded', label: `Day ${i + 1}`, }))} + label="90 days — 97.8% uptime" /> diff --git a/src/components/Chart/BarChart.tsx b/src/components/Chart/BarChart.tsx index 55f2191..d581d0a 100644 --- a/src/components/Chart/BarChart.tsx +++ b/src/components/Chart/BarChart.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import clsx from 'clsx'; -import { linearScale, niceTicks } from './utils'; +import { linearScale, niceTicks, thinIndices, dynamicTickTarget, measureLabelWidth, axisPadForLabels } from './utils'; import { useResizeWidth } from './hooks'; import { type Series, @@ -12,15 +12,17 @@ import { PAD_TOP, PAD_RIGHT, PAD_BOTTOM_AXIS, - PAD_LEFT_AXIS, BAR_GROUP_GAP, BAR_ITEM_GAP, resolveSeries, resolveTooltipMode, + axisTickTarget, } from './types'; import { ChartWrapper } from './ChartWrapper'; import styles from './Chart.module.scss'; +const EMPTY_TICKS = { min: 0, max: 1, ticks: [0, 1] } as const; + export interface BarChartProps extends React.ComponentPropsWithoutRef<'div'> { data: Record[]; dataKey?: string; @@ -121,17 +123,12 @@ export const Bar = React.forwardRef( const showValueAxis = grid; const padBottom = !isHorizontal && showCategoryAxis ? PAD_BOTTOM_AXIS : 0; - const padLeft = isHorizontal ? (showCategoryAxis ? 60 : 12) : (showValueAxis ? PAD_LEFT_AXIS : 0); - const padRight = isHorizontal && showValueAxis ? 40 : PAD_RIGHT; - const plotWidth = Math.max(0, width - padLeft - padRight); const plotHeight = Math.max(0, height - PAD_TOP - padBottom); - // Value domain - const { yMin, yMax, yTicks } = React.useMemo(() => { - if (yDomain) { - const result = niceTicks(yDomain[0], yDomain[1], 5); - return { yMin: result.min, yMax: result.max, yTicks: result.ticks }; - } + // Value domain — split into raw max + tick generation so we can + // measure formatted labels before choosing padding and tick count. + const rawValueMax = React.useMemo(() => { + if (yDomain) return yDomain[1]; let max = -Infinity; if (stacked) { for (let i = 0; i < data.length; i++) { @@ -154,11 +151,49 @@ export const Bar = React.forwardRef( if (rl.value > max) max = rl.value; } } - if (max === -Infinity) max = 1; - const result = niceTicks(0, max, 5); - return { yMin: result.min, yMax: result.max, yTicks: result.ticks }; + return max === -Infinity ? 1 : max; }, [data, series, stacked, referenceLines, yDomain]); + // Vertical: compute ticks first, then measure labels for padLeft. + // Horizontal: measure category labels for padLeft, then compute + // plotWidth and tick count from formatted value labels. + const verticalTickTarget = axisTickTarget(plotHeight); + const verticalTicks = React.useMemo(() => { + if (isHorizontal) return EMPTY_TICKS; + const domainMin = yDomain ? yDomain[0] : 0; + const domainMax = yDomain ? yDomain[1] : rawValueMax; + return niceTicks(domainMin, domainMax, verticalTickTarget); + }, [isHorizontal, rawValueMax, yDomain, verticalTickTarget]); + + const padLeft = React.useMemo(() => { + if (isHorizontal) { + if (!showCategoryAxis || !xKey) return 12; + const fmt = formatXLabel ?? ((v: unknown) => String(v ?? '')); + const maxWidth = Math.max(...data.map((d) => measureLabelWidth(fmt(d[xKey])))); + return Math.max(12, Math.ceil(maxWidth) + 12); + } + if (!showValueAxis) return 0; + const fmt = formatYLabel ?? ((v: number) => String(v)); + return axisPadForLabels(verticalTicks.ticks.map(fmt)); + }, [isHorizontal, showCategoryAxis, showValueAxis, xKey, data, formatXLabel, formatYLabel, verticalTicks.ticks]); + const padRight = isHorizontal && showValueAxis ? 40 : PAD_RIGHT; + const plotWidth = Math.max(0, width - padLeft - padRight); + + const tickTarget = React.useMemo(() => { + if (!isHorizontal) return verticalTickTarget; + const fmt = formatYLabel ?? ((v: number) => String(v)); + const samples = [fmt(0), fmt(rawValueMax), fmt(rawValueMax / 2), fmt(rawValueMax * 0.75)]; + return dynamicTickTarget(plotWidth, samples); + }, [isHorizontal, verticalTickTarget, plotWidth, rawValueMax, formatYLabel]); + + const { yMin, yMax, yTicks } = React.useMemo(() => { + if (!isHorizontal) return { yMin: verticalTicks.min, yMax: verticalTicks.max, yTicks: verticalTicks.ticks }; + const domainMin = yDomain ? yDomain[0] : 0; + const domainMax = yDomain ? yDomain[1] : rawValueMax; + const result = niceTicks(domainMin, domainMax, tickTarget); + return { yMin: result.min, yMax: result.max, yTicks: result.ticks }; + }, [isHorizontal, verticalTicks, rawValueMax, yDomain, tickTarget]); + // Bar geometry — slot is along the category axis, bar extends along the value axis const categoryLength = isHorizontal ? plotHeight : plotWidth; const slotSize = data.length > 0 ? categoryLength / data.length : 0; @@ -453,21 +488,20 @@ export const Bar = React.forwardRef( {/* Category axis labels (thinned to avoid overlap) */} {xKey && (() => { const maxLabels = isHorizontal - ? Math.max(1, Math.floor(plotHeight / 24)) - : Math.max(1, Math.floor(plotWidth / 50)); - const step = data.length <= maxLabels ? 1 : Math.ceil(data.length / maxLabels); - return data.map((d, i) => { - if (i % step !== 0 && i !== data.length - 1) return null; - return isHorizontal ? ( + ? Math.max(2, Math.floor(plotHeight / 24)) + : Math.max(2, Math.floor(plotWidth / 60)); + const indices = thinIndices(data.length, maxLabels); + return indices.map((i) => + isHorizontal ? ( - {formatXLabel ? formatXLabel(d[xKey]) : String(d[xKey] ?? '')} + {formatXLabel ? formatXLabel(data[i][xKey]) : String(data[i][xKey] ?? '')} ) : ( - {formatXLabel ? formatXLabel(d[xKey]) : String(d[xKey] ?? '')} + {formatXLabel ? formatXLabel(data[i][xKey]) : String(data[i][xKey] ?? '')} - ); - }); + ), + ); })()} diff --git a/src/components/Chart/Chart.module.scss b/src/components/Chart/Chart.module.scss index 3182704..b1e48ef 100644 --- a/src/components/Chart/Chart.module.scss +++ b/src/components/Chart/Chart.module.scss @@ -17,7 +17,7 @@ .gridLine { stroke: var(--text-primary); - stroke-opacity: var(--chart-grid-opacity, 0.06); + stroke-opacity: var(--chart-grid-opacity, 0.18); stroke-width: 1; stroke-dasharray: 1 3; } @@ -611,6 +611,7 @@ .uptimeBars { display: flex; + align-items: flex-end; gap: 4px; width: 100%; } @@ -619,15 +620,15 @@ width: 3px; flex-shrink: 0; height: 100%; - transition: opacity 150ms ease; + transition: height 150ms ease; @media (prefers-reduced-motion: reduce) { transition: none; } } -.uptimeBarDimmed { - opacity: 0.3; +.uptimeBarActive { + height: calc(100% + 4px); } .uptimeTooltip { diff --git a/src/components/Chart/Chart.stories.tsx b/src/components/Chart/Chart.stories.tsx index 2d51949..aa8ef6f 100644 --- a/src/components/Chart/Chart.stories.tsx +++ b/src/components/Chart/Chart.stories.tsx @@ -505,7 +505,7 @@ const uptimeData = Array.from({ length: 90 }, (_, i) => ({ export const Uptime: Story = { render: () => (
- +
), }; diff --git a/src/components/Chart/Chart.unit.test.ts b/src/components/Chart/Chart.unit.test.ts index 2305e27..5212913 100644 --- a/src/components/Chart/Chart.unit.test.ts +++ b/src/components/Chart/Chart.unit.test.ts @@ -16,9 +16,13 @@ import { linearInterpolator, filerp, stackData, + thinIndices, + measureLabelWidth, + dynamicTickTarget, + axisPadForLabels, type Point, } from './utils'; -import { resolveTooltipMode, resolveSeries, SERIES_COLORS } from './types'; +import { resolveTooltipMode, resolveSeries, SERIES_COLORS, axisTickTarget } from './types'; // --------------------------------------------------------------------------- // linearScale @@ -561,3 +565,163 @@ describe('stackData', () => { } }); }); + +// --------------------------------------------------------------------------- +// thinIndices +// --------------------------------------------------------------------------- + +describe('thinIndices', () => { + it('returns all indices when count fits', () => { + expect(thinIndices(5, 10)).toEqual([0, 1, 2, 3, 4]); + expect(thinIndices(3, 3)).toEqual([0, 1, 2]); + }); + + it('returns empty array for zero count', () => { + expect(thinIndices(0, 5)).toEqual([]); + }); + + it('returns [0] when maxVisible is 1', () => { + expect(thinIndices(10, 1)).toEqual([0]); + }); + + it('returns first and last when maxVisible is 2', () => { + expect(thinIndices(10, 2)).toEqual([0, 9]); + }); + + it('always includes first and last index', () => { + const result = thinIndices(100, 5); + expect(result[0]).toBe(0); + expect(result[result.length - 1]).toBe(99); + }); + + it('returns evenly distributed indices', () => { + const result = thinIndices(10, 4); + expect(result).toHaveLength(4); + expect(result[0]).toBe(0); + expect(result[result.length - 1]).toBe(9); + for (let i = 1; i < result.length; i++) { + expect(result[i]).toBeGreaterThan(result[i - 1]); + } + }); + + it('never returns more indices than count', () => { + expect(thinIndices(2, 10)).toHaveLength(2); + expect(thinIndices(1, 5)).toHaveLength(1); + }); + + it('handles single item', () => { + expect(thinIndices(1, 5)).toEqual([0]); + }); +}); + +// --------------------------------------------------------------------------- +// yTickTarget +// --------------------------------------------------------------------------- + +describe('axisTickTarget', () => { + it('returns at least 2 for very short charts', () => { + expect(axisTickTarget(30)).toBe(2); + expect(axisTickTarget(0)).toBe(2); + }); + + it('scales with plot height (vertical)', () => { + expect(axisTickTarget(100)).toBe(3); + expect(axisTickTarget(160)).toBe(5); + expect(axisTickTarget(300)).toBe(9); + }); + + it('returns more ticks for tall charts', () => { + expect(axisTickTarget(600)).toBeGreaterThan(axisTickTarget(200)); + }); + + it('uses wider spacing for horizontal axis', () => { + expect(axisTickTarget(300, true)).toBeLessThan(axisTickTarget(300, false)); + expect(axisTickTarget(300, true)).toBe(5); + expect(axisTickTarget(300, false)).toBe(9); + }); +}); + +// --------------------------------------------------------------------------- +// measureLabelWidth +// --------------------------------------------------------------------------- + +describe('measureLabelWidth', () => { + it('returns a positive number for non-empty text', () => { + const w = measureLabelWidth('4500'); + expect(w).toBeGreaterThan(0); + }); + + it('wider text returns a larger width', () => { + const short = measureLabelWidth('0'); + const long = measureLabelWidth('$1,234,567.00'); + expect(long).toBeGreaterThan(short); + }); + + it('returns 0 for empty string', () => { + expect(measureLabelWidth('')).toBe(0); + }); + + it('scales proportionally to character count (fallback path)', () => { + const w4 = measureLabelWidth('1234'); + const w8 = measureLabelWidth('12345678'); + expect(w8 / w4).toBeCloseTo(2, 0); + }); +}); + +// --------------------------------------------------------------------------- +// dynamicTickTarget +// --------------------------------------------------------------------------- + +describe('dynamicTickTarget', () => { + it('returns at least 2', () => { + expect(dynamicTickTarget(50, ['$1,000,000.00'])).toBeGreaterThanOrEqual(2); + }); + + it('fits more ticks for shorter labels', () => { + const shortLabels = dynamicTickTarget(400, ['0', '100']); + const longLabels = dynamicTickTarget(400, ['$1,234,567.00']); + expect(shortLabels).toBeGreaterThan(longLabels); + }); + + it('fits more ticks in wider axes', () => { + const narrow = dynamicTickTarget(200, ['4500']); + const wide = dynamicTickTarget(800, ['4500']); + expect(wide).toBeGreaterThan(narrow); + }); + + it('falls back to 60px spacing when no samples given', () => { + expect(dynamicTickTarget(300, [])).toBe(5); + }); +}); + +// --------------------------------------------------------------------------- +// axisPadForLabels +// --------------------------------------------------------------------------- + +describe('axisPadForLabels', () => { + it('returns 0 for empty labels', () => { + expect(axisPadForLabels([])).toBe(0); + }); + + it('returns at least MIN_AXIS_PAD for short labels', () => { + expect(axisPadForLabels(['0', '5'])).toBeGreaterThanOrEqual(24); + }); + + it('grows with longer labels', () => { + const short = axisPadForLabels(['0', '100']); + const long = axisPadForLabels(['$1,000,000', '$2,000,000']); + expect(long).toBeGreaterThan(short); + }); + + it('is driven by the widest label', () => { + const withShort = axisPadForLabels(['0', '5', '10']); + const withLong = axisPadForLabels(['0', '5', '10', '10,000']); + expect(withLong).toBeGreaterThan(withShort); + }); + + it('accounts for minus sign in negative labels', () => { + const positive = axisPadForLabels(['0', '1,000']); + const withNeg = axisPadForLabels(['-1,000', '0', '1,000']); + expect(withNeg).toBeGreaterThan(positive); + }); +}); diff --git a/src/components/Chart/ComposedChart.tsx b/src/components/Chart/ComposedChart.tsx index cf8d5e5..ab3b10e 100644 --- a/src/components/Chart/ComposedChart.tsx +++ b/src/components/Chart/ComposedChart.tsx @@ -9,6 +9,8 @@ import { linearPath, monotoneInterpolator, linearInterpolator, + thinIndices, + axisPadForLabels, type Point, } from './utils'; import { useResizeWidth, useChartScrub } from './hooks'; @@ -22,10 +24,10 @@ import { PAD_TOP, PAD_RIGHT, PAD_BOTTOM_AXIS, - PAD_LEFT_AXIS, BAR_GROUP_GAP, BAR_ITEM_GAP, resolveTooltipMode, + axisTickTarget, } from './types'; import { ChartWrapper } from './ChartWrapper'; import styles from './Chart.module.scss'; @@ -81,7 +83,7 @@ export interface ComposedChartProps extends React.ComponentPropsWithoutRef<'div' formatYLabelRight?: (value: number) => string; } -const PAD_RIGHT_DUAL = PAD_LEFT_AXIS; +const EMPTY_TICKS = { min: 0, max: 1, ticks: [0, 1] } as const; export const Composed = React.forwardRef( function Composed( @@ -143,15 +145,15 @@ export const Composed = React.forwardRef( const lineSeries = React.useMemo(() => series.filter((s) => s.type === 'line'), [series]); const hasRightAxis = React.useMemo(() => series.some((s) => s.axis === 'right'), [series]); - // Geometry + // Geometry — plotHeight is independent of padLeft, + // so compute ticks first, then derive padLeft from label widths. const showXAxis = Boolean(xKey); const showYAxis = grid; const padBottom = showXAxis ? PAD_BOTTOM_AXIS : 0; - const padLeft = showYAxis ? PAD_LEFT_AXIS : 0; - const padRight = hasRightAxis ? PAD_RIGHT_DUAL : PAD_RIGHT; - const plotWidth = Math.max(0, width - padLeft - padRight); const plotHeight = Math.max(0, height - PAD_TOP - padBottom); + const tickTarget = axisTickTarget(plotHeight); + // Left Y domain (bar series + left-axis lines) const leftDomain = React.useMemo(() => { let max = -Infinity; @@ -167,12 +169,12 @@ export const Composed = React.forwardRef( } } if (max === -Infinity) max = 1; - return niceTicks(0, max, 5); - }, [data, series, referenceLines]); + return niceTicks(0, max, tickTarget); + }, [data, series, referenceLines, tickTarget]); // Right Y domain (right-axis lines) const rightDomain = React.useMemo(() => { - if (!hasRightAxis) return { min: 0, max: 1, ticks: [0, 1] }; + if (!hasRightAxis) return EMPTY_TICKS; let min = Infinity; let max = -Infinity; for (const s of series.filter((s) => s.axis === 'right')) { @@ -184,9 +186,21 @@ export const Composed = React.forwardRef( } } } - if (min === Infinity) return { min: 0, max: 1, ticks: [0, 1] }; - return niceTicks(min, max, 5); - }, [data, series, hasRightAxis]); + if (min === Infinity) return EMPTY_TICKS; + return niceTicks(min, max, tickTarget); + }, [data, series, hasRightAxis, tickTarget]); + + const padLeft = React.useMemo(() => { + if (!showYAxis) return 0; + const fmt = formatYLabel ?? ((v: number) => String(v)); + return axisPadForLabels(leftDomain.ticks.map(fmt)); + }, [showYAxis, leftDomain.ticks, formatYLabel]); + const padRight = React.useMemo(() => { + if (!hasRightAxis) return PAD_RIGHT; + const fmt = formatYLabelRight ?? ((v: number) => String(v)); + return axisPadForLabels(rightDomain.ticks.map(fmt)); + }, [hasRightAxis, rightDomain.ticks, formatYLabelRight]); + const plotWidth = Math.max(0, width - padLeft - padRight); // Bar geometry const slotWidth = data.length > 0 ? plotWidth / data.length : 0; @@ -445,9 +459,11 @@ export const Composed = React.forwardRef( ))} - {/* X axis labels */} - {xKey && - data.map((d, i) => ( + {/* X axis labels (thinned to avoid overlap) */} + {xKey && (() => { + const maxLabels = Math.max(2, Math.floor(plotWidth / 60)); + const indices = thinIndices(data.length, maxLabels); + return indices.map((i) => ( ( textAnchor="middle" dominantBaseline="auto" > - {formatXLabel ? formatXLabel(d[xKey]) : String(d[xKey] ?? '')} + {formatXLabel ? formatXLabel(data[i][xKey]) : String(data[i][xKey] ?? '')} - ))} + )); + })()} diff --git a/src/components/Chart/LineChart.tsx b/src/components/Chart/LineChart.tsx index 4cd23ec..352bd41 100644 --- a/src/components/Chart/LineChart.tsx +++ b/src/components/Chart/LineChart.tsx @@ -11,6 +11,8 @@ import { linearPath, monotoneInterpolator, linearInterpolator, + thinIndices, + axisPadForLabels, type Point, } from './utils'; import { useResizeWidth, useChartScrub } from './hooks'; @@ -22,10 +24,10 @@ import { PAD_TOP, PAD_RIGHT, PAD_BOTTOM_AXIS, - PAD_LEFT_AXIS, DASH_PATTERNS, resolveTooltipMode, resolveSeries, + axisTickTarget, } from './types'; import { ChartWrapper } from './ChartWrapper'; import styles from './Chart.module.scss'; @@ -164,26 +166,18 @@ export const Line = React.forwardRef( const fillOpacity = fillProp === true ? 0.08 : typeof fillProp === 'number' ? fillProp : 0.06; - // Chart area geometry + // Chart area geometry — plotHeight is independent of padLeft, + // so we compute ticks first, then derive padLeft from label widths. const showXAxis = Boolean(xKey); const showYAxis = grid; const padBottom = showXAxis ? PAD_BOTTOM_AXIS : 0; - const padLeft = showYAxis ? PAD_LEFT_AXIS : 0; - const plotWidth = Math.max(0, width - padLeft - PAD_RIGHT); const plotHeight = Math.max(0, height - PAD_TOP - padBottom); - // Left-edge fade - const fadeWidth = - fadeLeft === true ? 40 : typeof fadeLeft === 'number' ? fadeLeft : 0; - const hasFade = fadeWidth > 0 && plotWidth > 0; - const fadeMaskId = hasFade ? `${uid}-fade` : undefined; - const clipActiveId = `${uid}-clip-active`; - const clipInactiveId = `${uid}-clip-inactive`; + const tickTarget = axisTickTarget(plotHeight); - // Y domain with nice ticks const { yMin, yMax, yTicks } = React.useMemo(() => { if (yDomainProp) { - const result = niceTicks(yDomainProp[0], yDomainProp[1], 5); + const result = niceTicks(yDomainProp[0], yDomainProp[1], tickTarget); return { yMin: result.min, yMax: result.max, yTicks: result.ticks }; } let min = Infinity; @@ -208,9 +202,24 @@ export const Line = React.forwardRef( if (min === Infinity) { return { yMin: 0, yMax: 1, yTicks: [0, 1] }; } - const result = niceTicks(min, max, 5); + const result = niceTicks(min, max, tickTarget); return { yMin: result.min, yMax: result.max, yTicks: result.ticks }; - }, [data, series, referenceLines, yDomainProp]); + }, [data, series, referenceLines, yDomainProp, tickTarget]); + + const padLeft = React.useMemo(() => { + if (!showYAxis) return 0; + const fmt = formatYLabel ?? ((v: number) => String(v)); + return axisPadForLabels(yTicks.map(fmt)); + }, [showYAxis, yTicks, formatYLabel]); + const plotWidth = Math.max(0, width - padLeft - PAD_RIGHT); + + // Left-edge fade + const fadeWidth = + fadeLeft === true ? 40 : typeof fadeLeft === 'number' ? fadeLeft : 0; + const hasFade = fadeWidth > 0 && plotWidth > 0; + const fadeMaskId = hasFade ? `${uid}-fade` : undefined; + const clipActiveId = `${uid}-clip-active`; + const clipInactiveId = `${uid}-clip-inactive`; // Compute pixel points for each series const seriesPoints = React.useMemo(() => { @@ -251,17 +260,7 @@ export const Line = React.forwardRef( const xLabels = React.useMemo(() => { if (!xKey || data.length === 0 || plotWidth <= 0) return []; const maxLabels = Math.max(2, Math.floor(plotWidth / 60)); - let indices: number[]; - if (data.length <= maxLabels) { - indices = data.map((_, i) => i); - } else { - indices = [0]; - const step = (data.length - 1) / (maxLabels - 1); - for (let i = 1; i < maxLabels - 1; i++) { - indices.push(Math.round(i * step)); - } - indices.push(data.length - 1); - } + const indices = thinIndices(data.length, maxLabels); return indices.map((i) => { const x = data.length === 1 diff --git a/src/components/Chart/LiveChart.tsx b/src/components/Chart/LiveChart.tsx index 64459fa..0ea2790 100644 --- a/src/components/Chart/LiveChart.tsx +++ b/src/components/Chart/LiveChart.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import clsx from 'clsx'; -import { filerp } from './utils'; +import { filerp, CHART_LABEL_FONT } from './utils'; import styles from './Chart.module.scss'; export interface LivePoint { @@ -356,14 +356,14 @@ export const Live = React.forwardRef( if (alpha < 0.01) continue; const v = key / 1000; const y = Math.round(toY(v)) + 0.5; - ctx.globalAlpha = alpha * 0.06; + ctx.globalAlpha = alpha * 0.18; ctx.strokeStyle = `rgb(0,0,0)`; ctx.setLineDash([1, 3]); ctx.beginPath(); ctx.moveTo(padLeft, y); ctx.lineTo(padLeft + chartW, y); ctx.stroke(); ctx.setLineDash([]); ctx.globalAlpha = alpha * 0.4; ctx.fillStyle = 'rgb(0,0,0)'; - ctx.font = '11px "Suisse Intl Mono", "SF Mono", Menlo, monospace'; + ctx.font = CHART_LABEL_FONT; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; ctx.fillText(fmtVal(v), padLeft - 8, y); @@ -423,7 +423,7 @@ export const Live = React.forwardRef( const fmtTime = cfg.formatTime ?? formatDefaultTime; const timeStep = Math.max(1, Math.ceil(cfg.windowSecs / 5)); const firstT = Math.ceil(leftEdge / timeStep) * timeStep; - ctx.font = '11px "Suisse Intl Mono", "SF Mono", Menlo, monospace'; + ctx.font = CHART_LABEL_FONT; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillStyle = 'rgb(0,0,0)'; @@ -518,7 +518,7 @@ export const Live = React.forwardRef( const fmtTime = cfg.formatTime ?? formatDefaultTime; const label = `${fmtVal(hoverVal)} · ${fmtTime(hoverTime)}`; ctx.globalAlpha = opacity; - ctx.font = '12px "Suisse Intl Mono", "SF Mono", Menlo, monospace'; + ctx.font = CHART_LABEL_FONT.replace('11px', '12px'); ctx.textBaseline = 'top'; const labelWidth = ctx.measureText(label).width; diff --git a/src/components/Chart/StackedAreaChart.tsx b/src/components/Chart/StackedAreaChart.tsx index 3c76379..dbe1ea6 100644 --- a/src/components/Chart/StackedAreaChart.tsx +++ b/src/components/Chart/StackedAreaChart.tsx @@ -10,6 +10,8 @@ import { monotoneInterpolator, linearInterpolator, stackData, + thinIndices, + axisPadForLabels, type Point, } from './utils'; import { useResizeWidth, useChartScrub } from './hooks'; @@ -21,9 +23,9 @@ import { PAD_TOP, PAD_RIGHT, PAD_BOTTOM_AXIS, - PAD_LEFT_AXIS, resolveTooltipMode, resolveSeries, + axisTickTarget, } from './types'; import { ChartWrapper } from './ChartWrapper'; import styles from './Chart.module.scss'; @@ -115,8 +117,6 @@ export const StackedArea = React.forwardRef { if (yDomainProp) { - const result = niceTicks(yDomainProp[0], yDomainProp[1], 5); + const result = niceTicks(yDomainProp[0], yDomainProp[1], tickTarget); return { yMin: result.min, yMax: result.max, yTicks: result.ticks }; } let max = -Infinity; @@ -142,9 +144,16 @@ export const StackedArea = React.forwardRef { + if (!showYAxis) return 0; + const fmt = formatYLabel ?? ((v: number) => String(v)); + return axisPadForLabels(yTicks.map(fmt)); + }, [showYAxis, yTicks, formatYLabel]); + const plotWidth = Math.max(0, width - padLeft - PAD_RIGHT); // Compute pixel points for top edge of each band (for interpolators and line paths) const bandTopPoints = React.useMemo(() => { @@ -219,15 +228,7 @@ export const StackedArea = React.forwardRef { if (!xKey || data.length === 0 || plotWidth <= 0) return []; const maxLabels = Math.max(2, Math.floor(plotWidth / 60)); - let indices: number[]; - if (data.length <= maxLabels) { - indices = data.map((_, i) => i); - } else { - indices = [0]; - const step = (data.length - 1) / (maxLabels - 1); - for (let i = 1; i < maxLabels - 1; i++) indices.push(Math.round(i * step)); - indices.push(data.length - 1); - } + const indices = thinIndices(data.length, maxLabels); return indices.map((i) => { const x = data.length === 1 ? plotWidth / 2 : (i / (data.length - 1)) * plotWidth; const raw = data[i][xKey]; diff --git a/src/components/Chart/UptimeChart.tsx b/src/components/Chart/UptimeChart.tsx index 23dec79..431878a 100644 --- a/src/components/Chart/UptimeChart.tsx +++ b/src/components/Chart/UptimeChart.tsx @@ -20,8 +20,17 @@ export interface UptimeChartProps extends React.ComponentPropsWithoutRef<'div'> colors?: Partial>; /** Accessible label. */ ariaLabel?: string; - /** Show tooltip label on hover. Defaults to true. */ - tooltip?: boolean; + /** + * Always-visible resting label shown below the bars. On hover it + * updates to the hovered bar's label, then returns to this value. + * Set to `false` to hide the label row entirely. + */ + label?: string | false; + /** + * Status dot color shown next to the resting label. + * Ignored when a bar is hovered (uses the hovered bar's status color). + */ + labelStatus?: UptimePoint['status']; /** Called when a bar is hovered. */ onHover?: (point: UptimePoint | null, index: number | null) => void; } @@ -40,7 +49,8 @@ export const Uptime = React.forwardRef( barHeight = 32, colors: colorsProp, ariaLabel, - tooltip: showTooltip = true, + label: labelProp, + labelStatus = 'up', onHover, className, ...props @@ -49,6 +59,7 @@ export const Uptime = React.forwardRef( ) { const [activeIndex, setActiveIndex] = React.useState(null); const colors = { ...DEFAULT_COLORS, ...colorsProp }; + const showLabel = labelProp !== false; const handleEnter = React.useCallback( (i: number) => { @@ -63,6 +74,10 @@ export const Uptime = React.forwardRef( onHover?.(null, null); }, [onHover]); + const activePoint = activeIndex !== null ? data[activeIndex] : null; + const displayLabel = activePoint?.label ?? labelProp ?? null; + const displayStatus = activePoint?.status ?? labelStatus; + return (
( key={i} className={clsx( styles.uptimeBar, - activeIndex !== null && activeIndex !== i && styles.uptimeBarDimmed, + activeIndex === i && styles.uptimeBarActive, )} style={{ backgroundColor: colors[point.status] }} onMouseEnter={() => handleEnter(i)} @@ -85,21 +100,21 @@ export const Uptime = React.forwardRef( /> ))}
- {showTooltip &&
- {activeIndex !== null && data[activeIndex]?.label && ( - <> - - {data[activeIndex].label} - - )} - {/* Reserve height when empty */} - {(activeIndex === null || !data[activeIndex]?.label) && ( -   - )} -
} + {showLabel && ( +
+ {displayLabel ? ( + <> + + {displayLabel} + + ) : ( + + )} +
+ )} ); }, diff --git a/src/components/Chart/types.ts b/src/components/Chart/types.ts index 1876d47..c26395c 100644 --- a/src/components/Chart/types.ts +++ b/src/components/Chart/types.ts @@ -53,7 +53,6 @@ export const SERIES_COLORS = [ export const PAD_TOP = 8; export const PAD_RIGHT = 8; export const PAD_BOTTOM_AXIS = 28; -export const PAD_LEFT_AXIS = 48; export const TOOLTIP_GAP = 12; export function resolveTooltipMode(prop: TooltipProp | undefined): TooltipMode { @@ -93,6 +92,16 @@ export function resolveSeries( export const BAR_GROUP_GAP = 0.12; export const BAR_ITEM_GAP = 1; +/** Minimum vertical spacing (px) between tick labels at 11px font. */ +export const MIN_TICK_SPACING_VERTICAL = 32; +/** Minimum horizontal spacing (px) between tick labels at 11px font. */ +export const MIN_TICK_SPACING_HORIZONTAL = 60; + +export function axisTickTarget(axisLength: number, horizontal = false): number { + const spacing = horizontal ? MIN_TICK_SPACING_HORIZONTAL : MIN_TICK_SPACING_VERTICAL; + return Math.max(2, Math.floor(axisLength / spacing)); +} + export const DASH_PATTERNS: Record = { solid: undefined, dashed: '4 4', diff --git a/src/components/Chart/utils.ts b/src/components/Chart/utils.ts index 9037aee..29c2e27 100644 --- a/src/components/Chart/utils.ts +++ b/src/components/Chart/utils.ts @@ -1,3 +1,53 @@ +export const CHART_LABEL_FONT = '11px "Suisse Intl Mono", "SF Mono", Menlo, monospace'; +const LABEL_PADDING = 16; +const FALLBACK_CHAR_WIDTH = 6.6; + +let _measureCtx: CanvasRenderingContext2D | null = null; + +/** + * Measure the pixel width of a chart axis label using an offscreen canvas. + * Falls back to a character-count estimate when running outside a browser. + */ +export function measureLabelWidth(text: string): number { + if (typeof document === 'undefined') return text.length * FALLBACK_CHAR_WIDTH; + if (!_measureCtx) { + _measureCtx = document.createElement('canvas').getContext('2d'); + } + if (!_measureCtx) return text.length * FALLBACK_CHAR_WIDTH; + _measureCtx.font = CHART_LABEL_FONT; + return _measureCtx.measureText(text).width; +} + +/** + * Compute how many axis labels fit along an axis of the given pixel length. + * Measures representative label texts to determine spacing dynamically. + */ +export function dynamicTickTarget( + axisLength: number, + sampleTexts: string[], +): number { + if (sampleTexts.length === 0) return Math.max(2, Math.floor(axisLength / 60)); + const maxWidth = Math.max(...sampleTexts.map(measureLabelWidth)); + return Math.max(2, Math.floor(axisLength / (maxWidth + LABEL_PADDING))); +} + +/** Minimum left padding so very short labels (e.g. "0") don't crowd the axis. */ +const MIN_AXIS_PAD = 24; +/** Gap between label right edge and plot area left edge. */ +const AXIS_LABEL_GAP = 8; +/** Extra margin on the left side to prevent label clipping at the container edge. */ +const AXIS_LABEL_INSET = 4; + +/** + * Compute the left padding needed to fit the widest label plus a gap. + * Used for Y-axis labels on vertical charts and category labels on horizontal. + */ +export function axisPadForLabels(labels: string[]): number { + if (labels.length === 0) return 0; + const maxWidth = Math.max(...labels.map(measureLabelWidth)); + return Math.max(MIN_AXIS_PAD, Math.ceil(maxWidth) + AXIS_LABEL_GAP + AXIS_LABEL_INSET); +} + export function filerp(current: number, target: number, speed: number, dt: number): number { const factor = 1 - Math.pow(1 - speed, dt / 16.67); return current + (target - current) * factor; @@ -226,6 +276,25 @@ export function monotoneInterpolator(points: Point[]): CurveInterpolator { }; } +/** + * Return evenly-spaced indices that always include the first and last item. + * Used to thin axis labels so they don't overlap. + */ +export function thinIndices(count: number, maxVisible: number): number[] { + if (count <= 0) return []; + if (count <= maxVisible) return Array.from({ length: count }, (_, i) => i); + if (maxVisible <= 1) return [0]; + if (maxVisible === 2) return [0, count - 1]; + + const indices: number[] = [0]; + const step = (count - 1) / (maxVisible - 1); + for (let i = 1; i < maxVisible - 1; i++) { + indices.push(Math.round(i * step)); + } + indices.push(count - 1); + return indices; +} + export interface StackedBand { key: string; baseline: number[];