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
5 changes: 4 additions & 1 deletion src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2003,7 +2003,7 @@ export default function Home() {
/>
</div>
<div style={{ width: 388 }}>
<p style={{ fontSize: 12, color: 'var(--text-secondary)', marginBottom: 8 }}>Area fill + compact tooltip</p>
<p style={{ fontSize: 12, color: 'var(--text-secondary)', marginBottom: 8 }}>Area fill + fadeLeft</p>
<Chart.Line
data={[
{ date: 'Mon', value: 120 },
Expand All @@ -2019,6 +2019,7 @@ export default function Home() {
height={200}
grid
fill
fadeLeft
tooltip="compact"
/>
</div>
Expand Down Expand Up @@ -2058,6 +2059,7 @@ export default function Home() {
height={200}
grid
tooltip
fadeLeft={60}
referenceLines={[
{ value: 90, label: 'Target' },
{ value: 75, label: 'Minimum' },
Expand Down Expand Up @@ -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"
/>
</div>
</div>
Expand Down
82 changes: 58 additions & 24 deletions src/components/Chart/BarChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string, unknown>[];
dataKey?: string;
Expand Down Expand Up @@ -121,17 +123,12 @@ export const Bar = React.forwardRef<HTMLDivElement, BarChartProps>(
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++) {
Expand All @@ -154,11 +151,49 @@ export const Bar = React.forwardRef<HTMLDivElement, BarChartProps>(
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;
Expand Down Expand Up @@ -453,21 +488,20 @@ export const Bar = React.forwardRef<HTMLDivElement, BarChartProps>(
{/* 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 ? (
<text key={i} x={-8} y={(i + 0.5) * slotSize} className={styles.axisLabel} textAnchor="end" dominantBaseline="middle">
{formatXLabel ? formatXLabel(d[xKey]) : String(d[xKey] ?? '')}
{formatXLabel ? formatXLabel(data[i][xKey]) : String(data[i][xKey] ?? '')}
</text>
) : (
<text key={i} x={(i + 0.5) * slotSize} y={plotHeight + 20} className={styles.axisLabel} textAnchor="middle" dominantBaseline="auto">
{formatXLabel ? formatXLabel(d[xKey]) : String(d[xKey] ?? '')}
{formatXLabel ? formatXLabel(data[i][xKey]) : String(data[i][xKey] ?? '')}
</text>
);
});
),
);
})()}
</g>
</svg>
Expand Down
9 changes: 5 additions & 4 deletions src/components/Chart/Chart.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -611,6 +611,7 @@

.uptimeBars {
display: flex;
align-items: flex-end;
gap: 4px;
width: 100%;
}
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/components/Chart/Chart.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -505,7 +505,7 @@ const uptimeData = Array.from({ length: 90 }, (_, i) => ({
export const Uptime: Story = {
render: () => (
<div style={{ width: 500 }}>
<Chart.Uptime data={uptimeData} />
<Chart.Uptime data={uptimeData} label="90 days — 97.8% uptime" />
</div>
),
};
Expand Down
166 changes: 165 additions & 1 deletion src/components/Chart/Chart.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
});
});
Loading