Skip to content

Commit

Permalink
Fixed: Add random and interval props to Histogram component (#1363
Browse files Browse the repository at this point in the history
)

* feat: add 'random' prop to Histogram for generating random frequency data

* feat: enable 'random' prop in PlayCard for Histogram component

* feat: add 'interval' prop to Histogram and update frequency data handling

* docs: Document `Histogram`'s `interval` prop

* fix(test): enhance Histogram tests for interval and animation frame updates

* refactor(stories): rename Histogram.stories.jsx to Histogram.stories.tsx and update export format

* refactor: Set `Histogram`'s random bar change interval to 200
  • Loading branch information
drikusroor authored Nov 15, 2024
1 parent e5c886c commit 68e1bac
Show file tree
Hide file tree
Showing 5 changed files with 249 additions and 74 deletions.
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

0 comments on commit 68e1bac

Please sign in to comment.