@exode-team/react-recorder 1.1.1 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
2
- import React, { useState, useRef, useCallback, useEffect, useMemo, useImperativeHandle, useLayoutEffect } from 'react';
2
+ import React, { useState, useRef, useCallback, useEffect, useMemo, useLayoutEffect, useImperativeHandle } from 'react';
3
3
  import styled from 'styled-components';
4
- import { Icon32Microphone, Icon16Microphone, Icon16Square, Icon16Pause, Icon16Play, Icon28CancelOutline, Icon28SendOutline, Icon20VoiceOutline, Icon24Pause, Icon24Play } from '@vkontakte/icons';
4
+ import { Icon32Microphone, Icon16Microphone, Icon16Pause, Icon16Play, Icon28DeleteOutlineAndroid, Icon28SendOutline, Icon20Voice, Icon24Pause, Icon24Play } from '@vkontakte/icons';
5
5
 
6
6
  /**
7
7
  * Math utilities
@@ -782,6 +782,7 @@ const useAudioBlobLoader = (options) => {
782
782
  const audioRef = useRef(null);
783
783
  const objectUrlRef = useRef(null);
784
784
  const abortControllerRef = useRef(null);
785
+ const failedSrcRef = useRef(null);
785
786
  const callbacksRef = useRef(playbackCallbacks);
786
787
  callbacksRef.current = playbackCallbacks;
787
788
  const createAudioFromBlob = useCallback((blob) => {
@@ -818,7 +819,7 @@ const useAudioBlobLoader = (options) => {
818
819
  }, [waveformData, createAudioFromBlob]);
819
820
  const ensureAudioLoaded = useCallback(async () => {
820
821
  var _a;
821
- if (audioRef.current) {
822
+ if (audioRef.current || failedSrcRef.current === src) {
822
823
  return;
823
824
  }
824
825
  if (preloadedBlob) {
@@ -849,6 +850,7 @@ const useAudioBlobLoader = (options) => {
849
850
  if (error instanceof DOMException && error.name === 'AbortError') {
850
851
  return;
851
852
  }
853
+ failedSrcRef.current = src;
852
854
  console.warn('[voice-player] Failed to load audio', error);
853
855
  }
854
856
  finally {
@@ -859,6 +861,7 @@ const useAudioBlobLoader = (options) => {
859
861
  }, [src, waveformData, preloadedBlob, mountFromBlob, createAudioFromBlob]);
860
862
  /** Cleanup on src change or unmount */
861
863
  useEffect(() => {
864
+ failedSrcRef.current = null;
862
865
  return () => {
863
866
  var _a;
864
867
  (_a = abortControllerRef.current) === null || _a === void 0 ? void 0 : _a.abort();
@@ -960,6 +963,13 @@ const useVoiceMessagePlayer = (options) => {
960
963
  },
961
964
  },
962
965
  });
966
+ /** Pre-fetch audio without playing (for viewport preloading) */
967
+ const prefetch = useCallback(() => {
968
+ if (isLoading || audioRef.current) {
969
+ return;
970
+ }
971
+ void ensureAudioLoaded();
972
+ }, [isLoading, ensureAudioLoaded]);
963
973
  /** Load audio and auto-play after loaded */
964
974
  const preload = useCallback(() => {
965
975
  if (isLoading || audioRef.current) {
@@ -1030,6 +1040,7 @@ const useVoiceMessagePlayer = (options) => {
1030
1040
  currentTime,
1031
1041
  waveformLevels,
1032
1042
  seekTo,
1043
+ prefetch,
1033
1044
  preload,
1034
1045
  formatTime,
1035
1046
  togglePlayback,
@@ -1180,7 +1191,6 @@ const useAudioRecorderHandlers = (options) => {
1180
1191
  const Container = styled.div `
1181
1192
  position: relative;
1182
1193
  width: 100%;
1183
- padding: 16px;
1184
1194
  transition: color 200ms ease, background-color 200ms ease;
1185
1195
  color: var(--text_primary, #0a0a0a);
1186
1196
  `;
@@ -1265,29 +1275,28 @@ const Wrapper$1 = styled.div `
1265
1275
  gap: 12px;
1266
1276
  padding: 10px 14px;
1267
1277
  border-radius: 9999px;
1268
- background: var(--background_content, #ffffff);
1269
- box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text_primary, #0a0a0a) 6%, transparent);
1278
+ background: var(--background_hover, color-mix(in srgb, var(--text_primary, #0a0a0a) 6%, transparent));
1270
1279
  `;
1271
1280
  const CancelButton$1 = styled.button `
1272
- width: 36px;
1273
- height: 36px;
1281
+ width: 38px;
1282
+ height: 38px;
1274
1283
  display: inline-flex;
1275
1284
  align-items: center;
1276
1285
  justify-content: center;
1277
- border-radius: 18px;
1278
- background: transparent;
1286
+ border-radius: 19px;
1287
+ background: color-mix(in srgb, var(--exode_red) 10%, transparent);
1279
1288
  border: 0;
1289
+ flex-shrink: 0;
1280
1290
  color: color-mix(in srgb, var(--text_primary, #0a0a0a) 54%, transparent);
1281
1291
  cursor: pointer;
1282
- transition: color 160ms ease, background-color 160ms ease;
1292
+ transition: transform 160ms ease;
1283
1293
 
1284
- &:hover {
1285
- background-color: color-mix(in srgb, var(--text_primary, #0a0a0a) 12%, transparent);
1286
- color: color-mix(in srgb, var(--text_primary, #0a0a0a) 76%, transparent);
1294
+ &:hover:not(:disabled) {
1295
+ transform: translateY(-1px);
1287
1296
  }
1288
1297
 
1289
- &:active {
1290
- background-color: color-mix(in srgb, var(--text_primary, #0a0a0a) 18%, transparent);
1298
+ &:active:not(:disabled) {
1299
+ transform: translateY(0);
1291
1300
  }
1292
1301
  `;
1293
1302
  const Track = styled.div `
@@ -1295,7 +1304,7 @@ const Track = styled.div `
1295
1304
  display: flex;
1296
1305
  align-items: center;
1297
1306
  gap: 12px;
1298
- padding: 8px 12px;
1307
+ padding: 8px 10px;
1299
1308
  border-radius: 9999px;
1300
1309
  background: color-mix(in srgb, var(--text_primary, #0a0a0a) 10%, transparent);
1301
1310
  `;
@@ -1459,7 +1468,9 @@ const RecordingShell = (props) => {
1459
1468
  return formatTime(0);
1460
1469
  }, [formatTime, hasRecording, isPlaying, playbackDuration, playbackTime, recordingTime]);
1461
1470
  const recordButtonDisabled = isSending;
1462
- const recordButtonIcon = !isRecording || isPaused ? jsx(Icon16Microphone, {}) : jsx(Icon16Square, {});
1471
+ const recordButtonIcon = !isRecording || isPaused
1472
+ ? jsx(Icon16Microphone, { width: 24, height: 24 })
1473
+ : jsx(Icon16Pause, { width: 24, height: 24 });
1463
1474
  const recordButtonVariant = !isRecording || isPaused ? 'resume' : 'record';
1464
1475
  const recordButtonAria = !isRecording
1465
1476
  ? startAriaLabel
@@ -1468,14 +1479,16 @@ const RecordingShell = (props) => {
1468
1479
  : pauseRecordingAriaLabel || stopAriaLabel;
1469
1480
  const canShowPlayback = !isRecording || isPaused;
1470
1481
  const playbackDisabled = !hasRecording && !isRecording;
1471
- const playbackIcon = isPlaying ? jsx(Icon16Pause, {}) : jsx(Icon16Play, {});
1482
+ const playbackIcon = isPlaying
1483
+ ? jsx(Icon16Pause, { width: 24, height: 24 })
1484
+ : jsx(Icon16Play, { width: 24, height: 24 });
1472
1485
  const playbackAria = isPlaying ? pausePlaybackAriaLabel : playAriaLabel;
1473
1486
  const sendDisabled = isSending || (!hasRecording && !isRecording);
1474
- return (jsxs(Wrapper$1, { children: [jsx(CancelButton$1, { type: "button", onClick: onCancel, "aria-label": cancelAriaLabel, children: jsx(Icon28CancelOutline, { width: 20, height: 20 }) }), jsxs(Track, { children: [jsx(CircleButton, { type: "button", "$variant": recordButtonVariant, onClick: onRecordToggle, "aria-label": recordButtonAria, disabled: recordButtonDisabled, children: recordButtonIcon }), !canShowPlayback ? null : (jsx(CircleButton, { type: "button", "$variant": "play", onClick: onTogglePlayback, "aria-label": playbackAria, disabled: playbackDisabled, children: playbackIcon })), jsx(Waveform, { "$expanded": canShowPlayback, children: jsx(WaveformBars, { values: waveformValues, divider: 100, scale: 42, minHeight: 6, styleInterpolator: ({ normalized }) => ({
1487
+ return (jsxs(Wrapper$1, { children: [jsx(CancelButton$1, { type: "button", onClick: onCancel, "aria-label": cancelAriaLabel, children: jsx(Icon28DeleteOutlineAndroid, { fill: "var(--exode_red)", width: 24, height: 24, style: { minWidth: 24 } }) }), jsxs(Track, { children: [jsx(CircleButton, { type: "button", "$variant": recordButtonVariant, onClick: onRecordToggle, "aria-label": recordButtonAria, disabled: recordButtonDisabled, children: recordButtonIcon }), !canShowPlayback ? null : (jsx(CircleButton, { type: "button", "$variant": "play", onClick: onTogglePlayback, "aria-label": playbackAria, disabled: playbackDisabled, children: playbackIcon })), jsx(Waveform, { "$expanded": canShowPlayback, children: jsx(WaveformBars, { values: waveformValues, divider: 100, scale: 42, minHeight: 6, styleInterpolator: ({ normalized }) => ({
1475
1488
  height: `${12 + normalized / 2}px`,
1476
1489
  maxHeight: '34px',
1477
1490
  opacity: 0.38 + Math.min(0.5, normalized / 120),
1478
- }) }) }), jsx(Timer$1, { children: displayTime })] }), jsx(SendButton$1, { type: "button", onClick: onSend, disabled: sendDisabled, "aria-label": sendAriaLabel, children: isSending ? (jsx(SpinnerSlot, { children: jsx(Spinner, {}) })) : (jsx(Icon28SendOutline, { width: 20, height: 20 })) })] }));
1491
+ }) }) }), jsx(Timer$1, { children: displayTime })] }), jsx(SendButton$1, { type: "button", onClick: onSend, disabled: sendDisabled, "aria-label": sendAriaLabel, children: isSending ? (jsx(SpinnerSlot, { children: jsx(Spinner, {}) })) : (jsx(Icon28SendOutline, { width: 24, height: 24 })) })] }));
1479
1492
  };
1480
1493
 
1481
1494
  /**
@@ -1735,8 +1748,8 @@ const useVoiceRecorderInput = (options) => {
1735
1748
 
1736
1749
  const PreviewBars = (props) => {
1737
1750
  var _a, _b;
1738
- const { isPlaying, playbackTime, lastDuration, previewLevels, recordingTime, timelineLevels, getVisibleBarsCount, } = props;
1739
- const maxBars = getVisibleBarsCount();
1751
+ const { isPlaying, playbackTime, lastDuration, previewLevels, recordingTime, timelineLevels, visibleBarsCount, } = props;
1752
+ const maxBars = visibleBarsCount;
1740
1753
  const sourceLevels = timelineLevels.length > 0
1741
1754
  ? timelineLevels
1742
1755
  : previewLevels;
@@ -1780,8 +1793,8 @@ const PreviewBars = (props) => {
1780
1793
  };
1781
1794
 
1782
1795
  const TimelineBars = (props) => {
1783
- const { timelineLevels, getVisibleBarsCount } = props;
1784
- const maxBars = getVisibleBarsCount();
1796
+ const { timelineLevels, visibleBarsCount } = props;
1797
+ const maxBars = visibleBarsCount;
1785
1798
  const visible = timelineLevels.slice(-maxBars);
1786
1799
  const padded = [
1787
1800
  ...visible,
@@ -1826,15 +1839,11 @@ const CancelButton = styled.button `
1826
1839
  border-radius: 16px;
1827
1840
  background: transparent;
1828
1841
  border: 0;
1842
+ margin-right: 4px;
1829
1843
  flex-shrink: 0;
1830
1844
  color: color-mix(in srgb, var(--text_primary, #0a0a0a) 45%, transparent);
1831
1845
  cursor: pointer;
1832
1846
  transition: color 160ms ease, background-color 160ms ease;
1833
-
1834
- &:hover {
1835
- background-color: color-mix(in srgb, var(--text_primary, #0a0a0a) 8%, transparent);
1836
- color: color-mix(in srgb, var(--text_primary, #0a0a0a) 72%, transparent);
1837
- }
1838
1847
  `;
1839
1848
  const RecordTrack = styled.div `
1840
1849
  flex: 1;
@@ -1850,7 +1859,8 @@ const RecordTrack = styled.div `
1850
1859
  const ButtonSlot = styled.div `
1851
1860
  display: flex;
1852
1861
  align-items: center;
1853
- gap: 2px;
1862
+ gap: 4px;
1863
+ padding: 2px;
1854
1864
  flex-shrink: 0;
1855
1865
  `;
1856
1866
  const StopButton = styled.button `
@@ -1987,19 +1997,39 @@ const VoiceRecorderInput = (props) => {
1987
1997
  const waveformRef = useRef(null);
1988
1998
  const { recorder, isSending, isPreview, timelineLevels, lastDurationRef, handleResume, handlePause, handleCancel, handleSendRequest, } = useVoiceRecorderInput(inputOptions);
1989
1999
  const { isPlaying, isRecording, recordingTime, hasRecording, previewLevels, playbackTime, formatTime, togglePlayback, } = recorder;
1990
- const getVisibleBarsCount = useCallback(() => {
2000
+ const [visibleBarsCount, setVisibleBarsCount] = useState(0);
2001
+ const measureBars = () => {
1991
2002
  const el = waveformRef.current;
1992
2003
  if (!el) {
1993
- return 60;
2004
+ return;
1994
2005
  }
1995
- return Math.floor(el.clientWidth / (TIMELINE_BAR_WIDTH + TIMELINE_BAR_GAP));
2006
+ setVisibleBarsCount(Math.floor(el.clientWidth / (TIMELINE_BAR_WIDTH + TIMELINE_BAR_GAP)));
2007
+ };
2008
+ /** Sync recalc before paint — catches layout shifts from button changes */
2009
+ useLayoutEffect(measureBars);
2010
+ /** ResizeObserver for external size changes (window resize, container resize) */
2011
+ useEffect(() => {
2012
+ const el = waveformRef.current;
2013
+ if (!el) {
2014
+ return;
2015
+ }
2016
+ let rafId = 0;
2017
+ const observer = new ResizeObserver(() => {
2018
+ cancelAnimationFrame(rafId);
2019
+ rafId = requestAnimationFrame(measureBars);
2020
+ });
2021
+ observer.observe(el);
2022
+ return () => {
2023
+ cancelAnimationFrame(rafId);
2024
+ observer.disconnect();
2025
+ };
1996
2026
  }, []);
1997
2027
  const displayTime = (isPreview && isPlaying)
1998
2028
  ? formatTime(playbackTime)
1999
2029
  : formatTime(recordingTime || lastDurationRef.current);
2000
- return (jsxs(Wrapper, { className: className, children: [jsx(CancelButton, { type: "button", onClick: handleCancel, "aria-label": "Cancel", children: jsx(Icon28CancelOutline, { width: 20, height: 20 }) }), jsxs(RecordTrack, { children: [jsx(ButtonSlot, { children: isPreview ? (jsxs(Fragment, { children: [jsx(MicIndicator, { type: "button", onClick: handleResume, "aria-label": "Resume recording", children: jsx(Icon20VoiceOutline, { width: 14, height: 14 }) }), jsx(PlayPauseButton, { type: "button", onClick: togglePlayback, "aria-label": isPlaying ? 'Pause' : 'Play', children: isPlaying
2030
+ return (jsxs(Wrapper, { className: className, children: [jsx(CancelButton, { type: "button", onClick: handleCancel, "aria-label": "Cancel", children: jsx(Icon28DeleteOutlineAndroid, { fill: "var(--exode_red)", width: 28, height: 28, style: { minWidth: 28 } }) }), jsxs(RecordTrack, { children: [jsx(ButtonSlot, { children: isPreview ? (jsxs(Fragment, { children: [jsx(MicIndicator, { type: "button", onClick: handleResume, "aria-label": "Resume recording", children: jsx(Icon20Voice, { width: 14, height: 14 }) }), jsx(PlayPauseButton, { type: "button", onClick: togglePlayback, "aria-label": isPlaying ? 'Pause' : 'Play', children: isPlaying
2001
2031
  ? jsx(Icon16Pause, { width: 14, height: 14 })
2002
- : jsx(Icon16Play, { width: 14, height: 14 }) })] })) : (jsx(StopButton, { type: "button", onClick: handlePause, "aria-label": "Pause recording", children: jsx(Icon16Square, { width: 12, height: 12 }) })) }), jsx(WaveformArea, { ref: waveformRef, children: isPreview ? (jsx(PreviewBars, { timelineLevels: timelineLevels, previewLevels: previewLevels, isPlaying: isPlaying, playbackTime: playbackTime, recordingTime: recordingTime, lastDuration: lastDurationRef.current, getVisibleBarsCount: getVisibleBarsCount })) : (jsx(TimelineBars, { timelineLevels: timelineLevels, getVisibleBarsCount: getVisibleBarsCount })) }), jsx(Timer, { children: displayTime })] }), jsx(SendButton, { type: "button", onClick: handleSendRequest, disabled: isSending || (!hasRecording && !isRecording), "aria-label": "Send", children: isSending ? (jsx(SpinnerIcon, {})) : (jsx(Icon28SendOutline, {})) })] }));
2032
+ : jsx(Icon16Play, { width: 14, height: 14 }) })] })) : (jsx(StopButton, { type: "button", onClick: handlePause, "aria-label": "Pause recording", children: jsx(Icon16Pause, { width: 14, height: 14 }) })) }), jsx(WaveformArea, { ref: waveformRef, children: isPreview ? (jsx(PreviewBars, { timelineLevels: timelineLevels, previewLevels: previewLevels, isPlaying: isPlaying, playbackTime: playbackTime, recordingTime: recordingTime, lastDuration: lastDurationRef.current, visibleBarsCount: visibleBarsCount })) : (jsx(TimelineBars, { timelineLevels: timelineLevels, visibleBarsCount: visibleBarsCount })) }), jsx(Timer, { children: displayTime })] }), jsx(SendButton, { type: "button", onClick: handleSendRequest, disabled: isSending || (!hasRecording && !isRecording), "aria-label": "Send", children: isSending ? (jsx(SpinnerIcon, {})) : (jsx(Icon28SendOutline, {})) })] }));
2003
2033
  };
2004
2034
 
2005
2035
  /**
@@ -2128,8 +2158,9 @@ const BAR_GAP = 2;
2128
2158
  const BAR_MIN_HEIGHT = 2;
2129
2159
  const BAR_MAX_HEIGHT = 24;
2130
2160
  const VoiceMessagePlayer = React.forwardRef((props, ref) => {
2131
- const { src, onClick, onEnded, chainId, duration, className, waveformData, } = props;
2161
+ const { src, onClick, onEnded, chainId, duration, className, waveformData, autoPreload = true, } = props;
2132
2162
  const trackRef = useRef(null);
2163
+ const wrapperRef = useRef(null);
2133
2164
  const [trackWidth, setTrackWidth] = useState(0);
2134
2165
  const handleEnded = useCallback(() => {
2135
2166
  onEnded === null || onEnded === void 0 ? void 0 : onEnded();
@@ -2137,25 +2168,66 @@ const VoiceMessagePlayer = React.forwardRef((props, ref) => {
2137
2168
  VoicePlaybackChain.playNext(chainId);
2138
2169
  }
2139
2170
  }, [onEnded, chainId]);
2140
- const { play, progress, isLoaded, isPlaying, isLoading, currentTime, waveformLevels, seekTo, preload, formatTime, togglePlayback, duration: resolvedDuration, } = useVoiceMessagePlayer({
2171
+ const { play, progress, isLoaded, isPlaying, isLoading, currentTime, waveformLevels, seekTo, prefetch, preload, formatTime, togglePlayback, duration: resolvedDuration, } = useVoiceMessagePlayer({
2141
2172
  src,
2142
2173
  duration,
2143
2174
  waveformData,
2144
2175
  preloadedBlob: props.preloadedBlob,
2145
2176
  onEnded: handleEnded,
2146
2177
  });
2147
- useImperativeHandle(ref, () => ({ play }), [play]);
2178
+ useImperativeHandle(ref, () => ({ play, prefetch }), [play, prefetch]);
2179
+ /** Auto-preload audio when player enters viewport */
2180
+ useEffect(() => {
2181
+ if (!autoPreload || isLoaded || isLoading) {
2182
+ return;
2183
+ }
2184
+ const el = wrapperRef.current;
2185
+ if (!el) {
2186
+ return;
2187
+ }
2188
+ const observer = new IntersectionObserver(([entry]) => {
2189
+ if (entry.isIntersecting) {
2190
+ prefetch();
2191
+ observer.disconnect();
2192
+ }
2193
+ }, { threshold: 0.1 });
2194
+ observer.observe(el);
2195
+ return () => observer.disconnect();
2196
+ }, [
2197
+ isLoaded,
2198
+ prefetch,
2199
+ isLoading,
2200
+ autoPreload,
2201
+ ]);
2148
2202
  /** Register in playback chain for sequential playback */
2149
2203
  useEffect(() => {
2150
2204
  if (chainId != null) {
2151
2205
  return VoicePlaybackChain.register(chainId, () => play());
2152
2206
  }
2153
2207
  }, [chainId, play]);
2154
- /** Measure track width once after mount */
2155
- useLayoutEffect(() => {
2208
+ const measureTrackWidth = () => {
2156
2209
  if (trackRef.current) {
2157
2210
  setTrackWidth(trackRef.current.clientWidth);
2158
2211
  }
2212
+ };
2213
+ /** Sync recalc before paint — catches layout shifts */
2214
+ useLayoutEffect(measureTrackWidth);
2215
+ /** ResizeObserver for external size changes (window resize, container resize) */
2216
+ useEffect(() => {
2217
+ const el = trackRef.current;
2218
+ if (!el) {
2219
+ return;
2220
+ }
2221
+ let rafId = 0;
2222
+ const observer = new ResizeObserver(() => {
2223
+ cancelAnimationFrame(rafId);
2224
+ rafId = requestAnimationFrame(measureTrackWidth);
2225
+ });
2226
+ observer.observe(el);
2227
+ return () => {
2228
+ cancelAnimationFrame(rafId);
2229
+ observer.disconnect();
2230
+ };
2159
2231
  }, []);
2160
2232
  const displayTime = isPlaying
2161
2233
  ? formatTime(currentTime)
@@ -2191,7 +2263,7 @@ const VoiceMessagePlayer = React.forwardRef((props, ref) => {
2191
2263
  const peak = Math.max(...raw, 1);
2192
2264
  return raw.map((v) => (v / peak) * 100);
2193
2265
  }, [waveformLevels, trackWidth]);
2194
- return (jsxs(PlayerWrapper, { onClick: onClick, className: className, children: [jsx(PlayButton, { type: "button", disabled: isLoading || (!src && !props.preloadedBlob), onClick: isLoaded ? togglePlayback : preload, "aria-label": isPlaying ? 'Pause' : 'Play', children: isLoading ? (jsx(LoadingSpinner, {})) : isPlaying ? (jsx(Icon24Pause, {})) : (jsx(Icon24Play, {})) }), jsxs(ContentColumn, { children: [jsx(WaveformTrack, { ref: trackRef, onClick: handleTrackClick, children: interpolatedBars.map((level, i) => {
2266
+ return (jsxs(PlayerWrapper, { ref: wrapperRef, onClick: onClick, className: className, children: [jsx(PlayButton, { type: "button", disabled: isLoading || (!src && !props.preloadedBlob), onClick: isLoaded ? togglePlayback : preload, "aria-label": isPlaying ? 'Pause' : 'Play', children: isLoading ? (jsx(LoadingSpinner, {})) : isPlaying ? (jsx(Icon24Pause, {})) : (jsx(Icon24Play, {})) }), jsxs(ContentColumn, { children: [jsx(WaveformTrack, { ref: trackRef, onClick: handleTrackClick, children: interpolatedBars.map((level, i) => {
2195
2267
  const normalized = Math.min(100, level);
2196
2268
  const height = BAR_MIN_HEIGHT + (normalized / 100) * (BAR_MAX_HEIGHT - BAR_MIN_HEIGHT);
2197
2269
  const barProgress = interpolatedBars.length > 0