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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,19 @@ export function Sales() {
}
```

Need the chart to fill its parent? Wrap it in `<ResponsiveContainer>` —
a `ResizeObserver`-backed render-prop that hands the chart `{ width, height }`:

```tsx
import { BarChart, ResponsiveContainer } from 'goldenchart';

<ResponsiveContainer aspectRatio={16 / 9} maxHeight={400}>
{({ width, height }) => (
<BarChart width={width} height={height} data={data} />
)}
</ResponsiveContainer>
```

## Compose your own

Every chart is built from reusable primitives, so you can draw arbitrary diagrams.
Expand Down
53 changes: 53 additions & 0 deletions src/components/ResponsiveContainer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { computeResponsiveSize } from './ResponsiveContainer';

// The React component itself is a thin ResizeObserver wrapper. Project
// convention (see InteractiveChart.test.ts / readMark.test.ts) is to skip
// jsdom and unit-test the pure helper that drives behaviour.
describe('computeResponsiveSize', () => {
it('derives height from width / aspectRatio when the rect has no height', () => {
expect(computeResponsiveSize({ width: 400, height: 0 }, { aspectRatio: 2 })).toEqual({
width: 400,
height: 200,
});
});

it('uses the observed height when the parent constrains it', () => {
expect(computeResponsiveSize({ width: 400, height: 250 }, { aspectRatio: 2 })).toEqual({
width: 400,
height: 250,
});
});

it('defaults to 16/9 when aspectRatio is omitted', () => {
const out = computeResponsiveSize({ width: 1600, height: 0 });
expect(out.width).toBe(1600);
expect(Math.round(out.height)).toBe(900);
});

it('clamps height to maxHeight', () => {
expect(
computeResponsiveSize({ width: 1000, height: 0 }, { aspectRatio: 1, maxHeight: 200 }),
).toEqual({ width: 1000, height: 200 });
});

it('clamps height to minHeight', () => {
expect(
computeResponsiveSize({ width: 20, height: 0 }, { aspectRatio: 1, minHeight: 80 }),
).toEqual({ width: 20, height: 80 });
});

it('clamps width to minWidth', () => {
expect(computeResponsiveSize({ width: 50, height: 0 }, { minWidth: 200 })).toEqual({
width: 200,
height: 200 / (16 / 9),
});
});

it('treats heights of 1 or less as unconstrained (avoids 0-height ResizeObserver noise)', () => {
expect(computeResponsiveSize({ width: 400, height: 1 }, { aspectRatio: 2 })).toEqual({
width: 400,
height: 200,
});
});
});
117 changes: 117 additions & 0 deletions src/components/ResponsiveContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { useEffect, useRef, useState } from 'react';
import type { CSSProperties, ReactNode } from 'react';

export interface ResponsiveSize {
width: number;
height: number;
}

export interface ResponsiveContainerProps {
/** Render-prop receiving the measured size in pixels. */
children: (size: ResponsiveSize) => ReactNode;
/** Width-to-height ratio used to derive `height` from observed width. Default 16/9. */
aspectRatio?: number;
/** Lower bound on the emitted width. */
minWidth?: number;
/** Lower bound on the emitted height. */
minHeight?: number;
/** Upper bound on the emitted height. */
maxHeight?: number;
/** Resize debounce in ms. Default 80. */
debounceMs?: number;
/**
* Initial size used during SSR / before the first measurement. When omitted,
* the container renders nothing until it has measured its parent — which is
* the safe default but means a one-frame layout shift in the browser.
*/
defaultSize?: ResponsiveSize;
className?: string;
style?: CSSProperties;
}

function clamp(value: number, min?: number, max?: number): number {
let out = value;
if (typeof min === 'number') out = Math.max(out, min);
if (typeof max === 'number') out = Math.min(out, max);
return out;
}

/**
* Pure size derivation: takes a parent's observed rect plus the container's
* `aspectRatio` / clamp options and returns the size that will be passed to
* the render-prop. Extracted so we can unit-test it without mounting React.
*/
export function computeResponsiveSize(
rect: { width: number; height: number },
opts: {
aspectRatio?: number;
minWidth?: number;
minHeight?: number;
maxHeight?: number;
} = {},
): ResponsiveSize {
const { aspectRatio = 16 / 9, minWidth, minHeight, maxHeight } = opts;
const width = clamp(rect.width, minWidth);
const derived = width / Math.max(aspectRatio, 0.01);
const observed = rect.height > 1 ? rect.height : derived;
const height = clamp(observed, minHeight, maxHeight);
return { width, height };
}

/**
* Width-driven render-prop wrapper. Measures its own `<div>` with
* `ResizeObserver` and hands `{ width, height }` to its child render fn so
* GoldenChart components — which require explicit pixel dimensions — fill the
* available width.
*
* SSR-safe: when no `defaultSize` is provided the container renders nothing
* until the first measurement. Pass `defaultSize` to render markup during SSR
* (it will be replaced by the measured size on hydration).
*/
export function ResponsiveContainer({
children,
aspectRatio = 16 / 9,
minWidth,
minHeight,
maxHeight,
debounceMs = 80,
defaultSize,
className,
style,
}: ResponsiveContainerProps) {
const ref = useRef<HTMLDivElement | null>(null);
const [size, setSize] = useState<ResponsiveSize | null>(defaultSize ?? null);

useEffect(() => {
const node = ref.current;
if (!node || typeof ResizeObserver === 'undefined') return;

let timer: ReturnType<typeof setTimeout> | null = null;
const apply = (rect: { width: number; height: number }) => {
setSize(computeResponsiveSize(rect, { aspectRatio, minWidth, minHeight, maxHeight }));
};

const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const rect = entry.contentRect;
if (debounceMs > 0) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => apply(rect), debounceMs);
} else {
apply(rect);
}
});
observer.observe(node);
return () => {
observer.disconnect();
if (timer) clearTimeout(timer);
};
}, [aspectRatio, minWidth, minHeight, maxHeight, debounceMs]);

return (
<div ref={ref} className={className} style={{ width: '100%', ...style }}>
{size ? children(size) : null}
</div>
);
}
2 changes: 2 additions & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export { Surface } from './Surface';
export type { SurfaceProps } from './Surface';
export { ResponsiveContainer } from './ResponsiveContainer';
export type { ResponsiveContainerProps, ResponsiveSize } from './ResponsiveContainer';
export { BarChart } from './BarChart';
export type { BarChartProps, BarMode } from './BarChart';
export { LineChart } from './LineChart';
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export type { RoughPathInfo } from './render/roughGenerator';
// High-level components
export {
Surface,
ResponsiveContainer,
BarChart,
LineChart,
AreaChart,
Expand Down Expand Up @@ -60,6 +61,8 @@ export {
} from './components';
export type {
SurfaceProps,
ResponsiveContainerProps,
ResponsiveSize,
BarChartProps,
BarMode,
LineChartProps,
Expand Down
Loading