Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixed: Add random and interval props to Histogram component #1363

Merged
merged 7 commits into from
Nov 15, 2024
144 changes: 120 additions & 24 deletions frontend/src/components/Histogram/Histogram.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,23 @@ import { render, act, waitFor } from '@testing-library/react';
import Histogram from './Histogram';

// Mock requestAnimationFrame and cancelAnimationFrame
vi.mock('global', () => ({
requestAnimationFrame: (callback: FrameRequestCallback): number => {
return setTimeout(callback, 0);
},
cancelAnimationFrame: (handle: number): void => {
clearTimeout(handle);
}
}));
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback): number => {
return setTimeout(callback, 16); // Approximate 60 FPS
});

describe('Histogram', () => {
vi.stubGlobal('cancelAnimationFrame', (handle: number): void => {
clearTimeout(handle);
});

// Mock setInterval and clearInterval
vi.useFakeTimers();

describe('Histogram', () => {
let mockAnalyser: {
getByteFrequencyData: vi.Mock;
};

beforeEach(() => {
vi.useFakeTimers({ toFake: ['requestAnimationFrame'] });

// Mock the Web Audio API
mockAnalyser = {
getByteFrequencyData: vi.fn(),
Expand All @@ -31,7 +30,7 @@ describe('Histogram', () => {
});

afterEach(() => {
vi.useRealTimers();
vi.clearAllTimers();
vi.restoreAllMocks();
});

Expand Down Expand Up @@ -126,32 +125,129 @@ describe('Histogram', () => {
expect(mockAnalyser.getByteFrequencyData).toHaveBeenCalled();
});

it('does not update bar heights when not running', async () => {
it('does not update bar heights when not running', () => {
const bars = 5;
mockAnalyser.getByteFrequencyData.mockImplementation((array) => {
for (let i = 0; i < array.length; i++) {
array[i] = Math.floor(Math.random() * 256);
}
mockAnalyser.getByteFrequencyData.mockImplementation(() => {
// This should not be called when running is false
});

const { container, rerender } = render(<Histogram running={false} bars={bars} />);
const { container } = render(<Histogram running={false} bars={bars} />);

const getHeights = () => Array.from(container.querySelectorAll('.aha__histogram > div')).map(
(bar) => bar.style.height
const getHeights = () =>
Array.from(container.querySelectorAll('.aha__histogram > div')).map(
(bar) => bar.style.height
);

const initialHeights = getHeights();

// Advance timers to simulate time passing
act(() => {
vi.advanceTimersByTime(1000); // Advance time by 1 second
});

const updatedHeights = getHeights();

expect(initialHeights).to.deep.equal(updatedHeights);
expect(mockAnalyser.getByteFrequencyData).not.toHaveBeenCalled();
});

it('updates bar heights based on random data when random is true and running is true', async () => {
const bars = 5;

// Ensure the analyser does not provide data
mockAnalyser.getByteFrequencyData.mockImplementation(() => { });

const { container, rerender } = render(
<Histogram running={true} bars={bars} random={true} />
);

const getHeights = () =>
Array.from(container.querySelectorAll('.aha__histogram > div')).map(
(bar) => bar.style.height
);

const initialHeights = getHeights();

// Advance timers and trigger animation frame
await waitFor(async () => {
vi.advanceTimersToNextFrame();
await act(async () => {
vi.advanceTimersByTime(100);
});

rerender(<Histogram running={false} bars={bars} />);
rerender(<Histogram running={true} bars={bars} random={true} />);

const updatedHeights = getHeights();

expect(initialHeights).to.deep.equal(updatedHeights);
expect(initialHeights).not.to.deep.equal(updatedHeights);
expect(mockAnalyser.getByteFrequencyData).not.toHaveBeenCalled();
});

it('does not call getByteFrequencyData when random is true', async () => {
const bars = 5;

const { rerender } = render(
<Histogram running={true} bars={bars} random={true} />
);

// Advance timers and trigger animation frame
await act(async () => {
vi.advanceTimersByTime(100);
});

rerender(<Histogram running={true} bars={bars} random={true} />);

expect(mockAnalyser.getByteFrequencyData).not.toHaveBeenCalled();
});

it('updates bar heights based on random data at the specified interval', async () => {
const bars = 5;
const interval = 200;

const { container } = render(
<Histogram running={true} bars={bars} random={true} interval={interval} />
);

const getHeights = () =>
Array.from(container.querySelectorAll('.aha__histogram > div')).map(
(bar) => bar.style.height
);

const initialHeights = getHeights();

// Advance timers by the interval to trigger the update
await act(async () => {
vi.advanceTimersByTime(interval);
});

const updatedHeights = getHeights();

expect(initialHeights).not.to.deep.equal(updatedHeights);
});

it('updates bar heights based on frequency data using requestAnimationFrame', async () => {
const bars = 5;
mockAnalyser.getByteFrequencyData.mockImplementation((array) => {
for (let i = 0; i < array.length; i++) {
array[i] = Math.floor(Math.random() * 256);
}
});

const { container } = render(<Histogram running={true} bars={bars} />);

const getHeights = () =>
Array.from(container.querySelectorAll('.aha__histogram > div')).map(
(bar) => bar.style.height
);

const initialHeights = getHeights();

// Advance timers to simulate requestAnimationFrame calls
await act(async () => {
vi.advanceTimersByTime(16); // Approximate time for one frame at 60 FPS
});

const updatedHeights = getHeights();

expect(initialHeights).not.to.deep.equal(updatedHeights);
expect(mockAnalyser.getByteFrequencyData).toHaveBeenCalled();
});
});
69 changes: 56 additions & 13 deletions frontend/src/components/Histogram/Histogram.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ interface HistogramProps {
marginTop?: number;
backgroundColor?: string;
borderRadius?: string;
random?: boolean;
/**
* If `random` is `true`, this prop sets the update interval in milliseconds.
* Default is 100 ms.
* Ignored when `random` is `false`.
*/
interval?: number;
}

const Histogram: React.FC<HistogramProps> = ({
Expand All @@ -19,40 +26,74 @@ const Histogram: React.FC<HistogramProps> = ({
marginTop = 0,
backgroundColor = undefined,
borderRadius = '0.15rem',
random = false,
interval = 100,
}) => {
const [frequencyData, setFrequencyData] = useState<Uint8Array>(new Uint8Array(bars));

const requestRef = useRef<number>();
const animationFrameRef = useRef<number>();
const intervalRef = useRef<number>();

useEffect(() => {
if (!running) {
if (requestRef.current) {
const emptyHistogram = new Uint8Array(bars);
setFrequencyData(emptyHistogram);
cancelAnimationFrame(requestRef.current);
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
const emptyHistogram = new Uint8Array(bars);
setFrequencyData(emptyHistogram);
return;
}

const updateFrequencyData = () => {
if (window.audioContext && window.analyzer) {
let dataWithoutExtremes: Uint8Array;

if (random) {
// Generate random frequency data
dataWithoutExtremes = new Uint8Array(bars);
for (let i = 0; i < bars; i++) {
dataWithoutExtremes[i] = Math.floor(Math.random() * 256);
}
setFrequencyData(dataWithoutExtremes);
} else if (window.audioContext && window.analyzer) {
const data = new Uint8Array(bars + 3);
window.analyzer.getByteFrequencyData(data);
// Remove the lower end of the frequency data
const dataWithoutExtremes = data.slice(3, bars + 3);
dataWithoutExtremes = data.slice(3, bars + 3);
setFrequencyData(dataWithoutExtremes);
animationFrameRef.current = requestAnimationFrame(updateFrequencyData);
return; // Exit the function to prevent setting another interval
} else {
dataWithoutExtremes = new Uint8Array(bars);
setFrequencyData(dataWithoutExtremes);
}
requestRef.current = requestAnimationFrame(updateFrequencyData);
};

requestRef.current = requestAnimationFrame(updateFrequencyData);
if (random) {
// Use setInterval when random is true
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
intervalRef.current = window.setInterval(updateFrequencyData, interval);
} else {
// Use requestAnimationFrame when random is false
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
animationFrameRef.current = requestAnimationFrame(updateFrequencyData);
}

return () => {
if (requestRef.current) {
cancelAnimationFrame(requestRef.current);
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [running, bars]);
}, [running, bars, random, interval]);

const barWidth = `calc((100% - ${(bars - 1) * spacing}px) / ${bars})`;

Expand All @@ -79,7 +120,9 @@ const Histogram: React.FC<HistogramProps> = ({
height: `${(frequencyData[index] / 255) * 100}%`,
backgroundColor: 'currentColor',
marginRight: index < bars - 1 ? spacing : 0,
transition: 'height 0.05s ease',
transition: random
? `height ${interval / 1000}s ease`
: 'height 0.05s ease',
}}
/>
))}
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/MatchingPairs/PlayCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ const PlayCard = ({ onClick, registerUserClicks, playing, section, view, showAni
bars={histogramBars}
backgroundColor="purple"
borderRadius=".5rem"
random={true}
interval={200}
/>
:
<div
Expand Down
37 changes: 0 additions & 37 deletions frontend/src/stories/Histogram.stories.jsx

This file was deleted.

Loading
Loading