universal-adaptive-bars 0.0.4 → 0.0.5

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/README.md CHANGED
@@ -3,7 +3,14 @@
3
3
  A highly customizable, interactive, and drill-down capable Bar Chart library for **React** and **React Native**.
4
4
 
5
5
  ![License](https://img.shields.io/badge/license-MIT-blue.svg)
6
- ![Version](https://img.shields.io/badge/version-0.0.1-green.svg)
6
+ ![Version](https://img.shields.io/badge/version-0.0.5-green.svg)
7
+
8
+ 📖 **Read the Story**: [Universal Adaptive Bars: The Smart Cross-Platform Charting Library You’ve Been Waiting For](https://sammeddoshi.medium.com/universal-adaptive-bars-the-smart-cross-platform-charting-library-youve-been-waiting-for-80501b0c0e3b)
9
+
10
+ ## New in v0.0.5 🚀
11
+ - **Calendar Jump**: Selecting a date in the calendar now "jumps" to that date (scrolling the view) instead of filtering data, preserving your ability to navigate back and forth.
12
+ - **Smart Stacked Radius**: Stacked bars now intelligently apply rounded corners only to the top and bottom segments for a polished UI.
13
+ - **Improved Alignment**: Perfect X-axis alignment for both standard and stacked bars.
7
14
 
8
15
  ## Features
9
16
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "universal-adaptive-bars",
3
3
  "private": false,
4
- "version": "0.0.4",
4
+ "version": "0.0.5",
5
5
  "description": "A highly customizable smart bar chart for React and React Native",
6
6
  "author": "Sammed Doshi <Sammeddoshi03.sd@gmail.com>",
7
7
  "repository": {
@@ -1,6 +1,6 @@
1
1
  import React, { useMemo, useState, useEffect } from 'react';
2
2
  import { scaleBand, scaleLinear } from 'd3-scale';
3
- import { getYear, getMonth, format, endOfWeek, eachWeekOfInterval, endOfMonth, isWithinInterval } from 'date-fns';
3
+ import { getYear, format, endOfWeek, eachWeekOfInterval, endOfMonth } from 'date-fns';
4
4
  import { useChartData } from './hooks/useChartData';
5
5
  import { Bar } from './components/Bar';
6
6
  import type { SmartBarChartProps, DataPoint } from './types';
@@ -10,7 +10,7 @@ export const SmartBarChart: React.FC<SmartBarChartProps> = ({
10
10
  data,
11
11
  view = 'month',
12
12
  variant = 'default',
13
- dataKeys = { date: 'date', value: 'value', label: 'label' },
13
+ dataKeys: providedDataKeys = { date: 'date', value: 'value', label: 'label' },
14
14
 
15
15
  geminiConfig,
16
16
  colors,
@@ -21,29 +21,39 @@ export const SmartBarChart: React.FC<SmartBarChartProps> = ({
21
21
  className = '',
22
22
  theme
23
23
  }) => {
24
+ // Helper for rounded paths
25
+ const getRoundedPath = (x: number, y: number, w: number, h: number, r: number, corners: { tl: boolean, tr: boolean, bl: boolean, br: boolean }) => {
26
+ const tl = corners.tl ? r : 0;
27
+ const tr = corners.tr ? r : 0;
28
+ const bl = corners.bl ? r : 0;
29
+ const br = corners.br ? r : 0;
30
+
31
+ return `
32
+ M ${x + tl} ${y}
33
+ L ${x + w - tr} ${y}
34
+ Q ${x + w} ${y} ${x + w} ${y + tr}
35
+ L ${x + w} ${y + h - br}
36
+ Q ${x + w} ${y + h} ${x + w - br} ${y + h}
37
+ L ${x + bl} ${y + h}
38
+ Q ${x} ${y + h} ${x} ${y + h - bl}
39
+ L ${x} ${y + tl}
40
+ Q ${x} ${y} ${x + tl} ${y}
41
+ Z
42
+ `;
43
+ };
24
44
  // Calendar / Filter State
25
45
  const [filterDate, setFilterDate] = useState<{ year: number | null, month: number | null, weekStartDate: Date | null }>({ year: null, month: null, weekStartDate: null });
46
+ const [tempFilterDate, setTempFilterDate] = useState<{ year: number | null, month: number | null, weekStartDate: Date | null }>({ year: null, month: null, weekStartDate: null });
26
47
  const [isPickerOpen, setIsPickerOpen] = useState(false);
27
48
  const [pickerMode, setPickerMode] = useState<'year' | 'month' | 'week'>('year');
28
49
 
29
- // Filter Raw Data BEFORE hooks
30
- const filteredRawData = useMemo(() => {
31
- if (!filterDate.year) return data;
32
- return data.filter(d => {
33
- const dateStr = d[dataKeys.date] as string;
34
- const date = new Date(dateStr);
35
- if (getYear(date) !== filterDate.year) return false;
36
- if (filterDate.month !== null && getMonth(date) !== filterDate.month) return false;
37
-
38
- if (filterDate.weekStartDate) {
39
- const start = filterDate.weekStartDate;
40
- const end = endOfWeek(start);
41
- return isWithinInterval(date, { start, end });
42
- }
50
+ const dataKeys = useMemo(() => providedDataKeys, [providedDataKeys.date, providedDataKeys.label, Array.isArray(providedDataKeys.value) ? providedDataKeys.value.join(',') : providedDataKeys.value]);
43
51
 
44
- return true;
45
- });
46
- }, [data, filterDate, dataKeys.date]);
52
+ // Filter Raw Data BEFORE hooks
53
+ // REF_CHANGE: We no longer filter the data here. We pass full data to useChartData
54
+ // and use the filterDate only to determining the initial Window Offset.
55
+ // However, we might still want to filter if the user explicitly wants to "Restrict" logic,
56
+ // but the request is to allow navigation. So we pass `data` directly.
47
57
 
48
58
  // Derived Years for Picker
49
59
  const availableYears = useMemo(() => {
@@ -51,7 +61,7 @@ export const SmartBarChart: React.FC<SmartBarChartProps> = ({
51
61
  return Array.from(years).sort((a, b) => b - a);
52
62
  }, [data, dataKeys.date]);
53
63
 
54
- const fullChartData = useChartData({ data: filteredRawData, view, dataKeys, colors });
64
+ const fullChartData = useChartData({ data, view, dataKeys, colors });
55
65
  const [activeItem, setActiveItem] = useState<DataPoint | null>(null);
56
66
  const [predictions, setPredictions] = useState<DataPoint[]>([]);
57
67
  const [isPredicting, setIsPredicting] = useState(false);
@@ -86,35 +96,98 @@ export const SmartBarChart: React.FC<SmartBarChartProps> = ({
86
96
 
87
97
  }, [view]);
88
98
 
89
- // Separate effect for filterDate so we don't create circular loops with the above
99
+ // Watch for filterDate changes to update Window Offset (Navigation / Jump)
90
100
  useEffect(() => {
91
- setWindowOffset(0);
101
+ if (!filterDate.year && !filterDate.weekStartDate && filterDate.month === null) return;
102
+
92
103
  setPredictions([]);
93
- }, [filterDate]);
104
+
105
+ // Find the index of the first item that matches our filter
106
+ // We need to look at 'fullChartData', but wait, 'fullChartData' depends on 'view'.
107
+ // If 'view' just changed, 'fullChartData' will update in next render.
108
+ // We might need to delay this check or simply calculate it.
109
+ // For now, let's assume 'fullChartData' is updated or will be.
110
+ // Actually, if we change View AND Filter same time, we need to be careful.
111
+
112
+ // Let's defer this logic slightly or assume the user cycle involves a render.
113
+ // Typically, we want to find the first data point that starts at or after our filter date.
114
+
115
+ let targetDate: Date | null = null;
116
+ if (filterDate.weekStartDate) targetDate = filterDate.weekStartDate;
117
+ else if (filterDate.month !== null && filterDate.year) targetDate = new Date(filterDate.year, filterDate.month, 1);
118
+ else if (filterDate.year) targetDate = new Date(filterDate.year, 0, 1);
119
+
120
+ if (targetDate && fullChartData.length > 0) {
121
+ // Find index
122
+ const idx = fullChartData.findIndex(d => d.date.getTime() >= targetDate!.getTime());
123
+ if (idx !== -1) {
124
+ // We want this 'idx' to be the first visible item (start of window).
125
+ // start = total - vis - offset
126
+ // idx = total - vis - offset
127
+ // offset = total - vis - idx
128
+
129
+ const total = fullChartData.length;
130
+ const newOffset = Math.max(0, total - VISIBLE_COUNT - idx);
131
+ setWindowOffset(newOffset);
132
+ }
133
+ }
134
+ }, [filterDate, fullChartData, VISIBLE_COUNT]); // Depend on fullChartData so it runs after data update
94
135
 
95
136
  const handleYearSelect = (year: number) => {
96
- setFilterDate({ year, month: null, weekStartDate: null });
137
+ setTempFilterDate({ year, month: null, weekStartDate: null });
97
138
  setPickerMode('month');
98
- onViewChange?.('month');
99
139
  };
100
140
 
101
141
  const handleMonthSelect = (monthIndex: number) => {
102
- setFilterDate(prev => ({ ...prev, month: monthIndex, weekStartDate: null }));
142
+ setTempFilterDate(prev => ({ ...prev, month: monthIndex, weekStartDate: null }));
103
143
  setPickerMode('week');
104
- onViewChange?.('week');
105
144
  };
106
145
 
107
146
  const handleWeekSelect = (weekStart: Date) => {
108
- setFilterDate(prev => ({ ...prev, weekStartDate: weekStart }));
147
+ setTempFilterDate(prev => ({ ...prev, weekStartDate: weekStart }));
148
+ // Logic change: Don't auto-close. Let them confirm.
149
+ };
150
+
151
+ const handleConfirm = () => {
152
+ setFilterDate(tempFilterDate);
109
153
  setIsPickerOpen(false);
110
- onViewChange?.('day');
154
+
155
+ // Auto-update view based on depth
156
+ let targetView: 'day' | 'week' | 'month' | undefined = undefined;
157
+ if (tempFilterDate.weekStartDate) targetView = 'day';
158
+ else if (tempFilterDate.month !== null) targetView = 'week';
159
+ else if (tempFilterDate.year !== null) targetView = 'month';
160
+
161
+ if (targetView) {
162
+ onViewChange?.(targetView);
163
+ }
164
+
165
+ // Calculate Offset to Jump To
166
+ // data is sorted ascending. windowOffset is from the END.
167
+ // windowOffset = 0 => shows [ ... , last ]
168
+ // start = totalLen - VISIBLE_COUNT - windowOffset
169
+ // We want 'start' to be the index of our target date.
170
+ // So windowOffset = totalLen - VISIBLE_COUNT - targetIndex
171
+
172
+ // Need to find targetIndex in *re-calculated* fullChartData for the NEW view.
173
+ // Since we can't wait for 'fullChartData' to update in this render cycle effectively
174
+ // (unless we wait for effect), we might need to rely on the fact that useChartData is fast
175
+ // or recalculate finding index in an effect.
176
+
177
+ // BETTER APPROACH:
178
+ // We just updated 'filterDate'. We can add an Effect that watches 'filterDate'
179
+ // and updates 'windowOffset' when it changes.
180
+ };
181
+
182
+ const handleOpenPicker = () => {
183
+ setTempFilterDate(filterDate); // Sync temp with current
184
+ setPickerMode(filterDate.year ? (filterDate.month !== null ? 'week' : 'month') : 'year');
185
+ setIsPickerOpen(true);
111
186
  };
112
187
 
113
188
  const clearFilter = () => {
114
- setFilterDate({ year: null, month: null, weekStartDate: null });
189
+ setTempFilterDate({ year: null, month: null, weekStartDate: null });
115
190
  setPickerMode('year');
116
- setIsPickerOpen(false);
117
- onViewChange?.('month'); // Reset View
118
191
  };
119
192
 
120
193
  // Data Slicing
@@ -190,7 +263,7 @@ export const SmartBarChart: React.FC<SmartBarChartProps> = ({
190
263
  const axisSize = theme?.axis?.fontSize || 10;
191
264
 
192
265
  return (
193
- <div className={`smart-bar-chart-wrapper ${className}`} style={{ width, display: 'flex', flexDirection: 'column', gap: 10, fontFamily: 'sans-serif', background: bg, padding: 16, borderRadius: 12 }}>
266
+ <div className={`smart-bar-chart-wrapper ${className}`} style={{ width, display: 'flex', flexDirection: 'column', gap: 10, fontFamily: 'sans-serif', background: bg, padding: 16, borderRadius: 12, position: 'relative' }}>
194
267
 
195
268
  {/* Legend / Info / Predict Header */}
196
269
  <div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', height: 40 }}>
@@ -218,7 +291,7 @@ export const SmartBarChart: React.FC<SmartBarChartProps> = ({
218
291
 
219
292
  {/* Calendar Button */}
220
293
  <button
221
- onClick={() => setIsPickerOpen(!isPickerOpen)}
294
+ onClick={() => isPickerOpen ? setIsPickerOpen(false) : handleOpenPicker()}
222
295
  style={{
223
296
  marginLeft: 10, padding: 6, cursor: 'pointer', background: '#fff', border: '1px solid #ddd', borderRadius: 4,
224
297
  display: 'flex', alignItems: 'center', justifyContent: 'center'
@@ -235,22 +308,30 @@ export const SmartBarChart: React.FC<SmartBarChartProps> = ({
235
308
  {/* Calendar Picker Popup */}
236
309
  {isPickerOpen && (
237
310
  <div style={{
238
- position: 'absolute', top: 50, right: 0, width: 240, background: '#fff',
311
+ position: 'absolute', top: 60, left: '50%', transform: 'translateX(-50%)', width: 260, background: '#fff',
239
312
  border: '1px solid #e0e0e0', borderRadius: 12,
240
- boxShadow: '0 10px 25px rgba(0,0,0,0.1)', zIndex: 30, padding: 16,
313
+ boxShadow: '0 10px 25px rgba(0,0,0,0.15)', zIndex: 30, padding: 16,
241
314
  fontFamily: 'sans-serif'
242
315
  }}>
243
316
  <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, alignItems: 'center' }}>
244
317
  {pickerMode !== 'year' && (
245
318
  <button
246
- onClick={() => setPickerMode(pickerMode === 'week' ? 'month' : 'year')}
319
+ onClick={() => {
320
+ if (pickerMode === 'week') {
321
+ setPickerMode('month');
322
+ setTempFilterDate(prev => ({ ...prev, weekStartDate: null }));
323
+ } else {
324
+ setPickerMode('year');
325
+ setTempFilterDate(prev => ({ ...prev, month: null, weekStartDate: null }));
326
+ }
327
+ }}
247
328
  style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 13, color: '#6366f1', fontWeight: 600 }}
248
329
  >
249
330
  &lt; Back
250
331
  </button>
251
332
  )}
252
333
  <div style={{ fontSize: 14, fontWeight: 'bold', color: '#1f2937', flex: 1, textAlign: pickerMode !== 'year' ? 'center' : 'left' }}>
253
- {pickerMode === 'year' ? 'Select Year' : pickerMode === 'month' ? `${filterDate.year}` : `${format(new Date(filterDate.year!, filterDate.month!, 1), 'MMM yyyy')}`}
334
+ {pickerMode === 'year' ? 'Select Year' : pickerMode === 'month' ? `${tempFilterDate.year}` : `${format(new Date(tempFilterDate.year!, tempFilterDate.month!, 1), 'MMM yyyy')}`}
254
335
  </div>
255
336
  <button
256
337
  onClick={() => { clearFilter(); }}
@@ -269,10 +350,10 @@ export const SmartBarChart: React.FC<SmartBarChartProps> = ({
269
350
  style={{
270
351
  padding: '8px 4px', fontSize: 13,
271
352
  border: '1px solid',
272
- borderColor: filterDate.year === year ? '#6366f1' : '#eee',
353
+ borderColor: tempFilterDate.year === year ? '#6366f1' : '#eee',
273
354
  borderRadius: 6,
274
- background: filterDate.year === year ? '#e0e7ff' : '#fff',
275
- color: filterDate.year === year ? '#4338ca' : '#374151',
355
+ background: tempFilterDate.year === year ? '#e0e7ff' : '#fff',
356
+ color: tempFilterDate.year === year ? '#4338ca' : '#374151',
276
357
  cursor: 'pointer',
277
358
  transition: 'all 0.2s'
278
359
  }}
@@ -289,10 +370,10 @@ export const SmartBarChart: React.FC<SmartBarChartProps> = ({
289
370
  style={{
290
371
  padding: '8px 4px', fontSize: 13,
291
372
  border: '1px solid',
292
- borderColor: filterDate.month === i ? '#6366f1' : '#eee',
373
+ borderColor: tempFilterDate.month === i ? '#6366f1' : '#eee',
293
374
  borderRadius: 6,
294
- background: filterDate.month === i ? '#e0e7ff' : '#fff',
295
- color: filterDate.month === i ? '#4338ca' : '#374151',
375
+ background: tempFilterDate.month === i ? '#e0e7ff' : '#fff',
376
+ color: tempFilterDate.month === i ? '#4338ca' : '#374151',
296
377
  cursor: 'pointer',
297
378
  transition: 'all 0.2s'
298
379
  }}
@@ -301,10 +382,10 @@ export const SmartBarChart: React.FC<SmartBarChartProps> = ({
301
382
  </button>
302
383
  ))
303
384
  )}
304
- {pickerMode === 'week' && filterDate.year && filterDate.month !== null && (
385
+ {pickerMode === 'week' && tempFilterDate.year && tempFilterDate.month !== null && (
305
386
  eachWeekOfInterval({
306
- start: new Date(filterDate.year, filterDate.month, 1),
307
- end: endOfMonth(new Date(filterDate.year, filterDate.month, 1))
387
+ start: new Date(tempFilterDate.year, tempFilterDate.month, 1),
388
+ end: endOfMonth(new Date(tempFilterDate.year, tempFilterDate.month, 1))
308
389
  }).map((weekStart, i) => {
309
390
  // Only show weeks that actually overlap with the month meaningfully?
310
391
  // eachWeekOfInterval gives standard weeks.
@@ -317,7 +398,7 @@ export const SmartBarChart: React.FC<SmartBarChartProps> = ({
317
398
  style={{
318
399
  padding: '8px 12px', fontSize: 13,
319
400
  border: '1px solid',
320
- borderColor: '#eee',
401
+ borderColor: tempFilterDate.weekStartDate?.getTime() === weekStart.getTime() ? '#6366f1' : '#eee',
321
402
  borderRadius: 6,
322
403
  background: '#fff',
323
404
  color: '#374151',
@@ -332,6 +413,27 @@ export const SmartBarChart: React.FC<SmartBarChartProps> = ({
332
413
  })
333
414
  )}
334
415
  </div>
416
+
417
+ <div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
418
+ <button
419
+ onClick={handleConfirm}
420
+ style={{
421
+ flex: 1, padding: '8px', background: '#6366f1', color: '#fff', border: 'none', borderRadius: 6,
422
+ fontSize: 13, fontWeight: 'bold', cursor: 'pointer'
423
+ }}
424
+ >
425
+ Confirm
426
+ </button>
427
+ <button
428
+ onClick={() => setIsPickerOpen(false)}
429
+ style={{
430
+ flex: 1, padding: '8px', background: '#f3f4f6', color: '#374151', border: 'none', borderRadius: 6,
431
+ fontSize: 13, fontWeight: 'bold', cursor: 'pointer'
432
+ }}
433
+ >
434
+ Cancel
435
+ </button>
436
+ </div>
335
437
  </div>
336
438
  )}
337
439
  </div>
@@ -429,15 +531,27 @@ export const SmartBarChart: React.FC<SmartBarChartProps> = ({
429
531
  {d.stackedValues.map((stack, i) => {
430
532
  const segmentHeight = Math.abs(yScale(stack.value) - yScale(0));
431
533
  const segmentY = currentY - segmentHeight;
534
+
535
+ // Determine corners
536
+ const isTop = i === d.stackedValues!.length - 1;
537
+ const isBottom = i === 0;
538
+
539
+ // Only top-most segment gets top radius.
540
+ // Only bottom-most segment gets bottom radius.
541
+ const corners = {
542
+ tl: isTop,
543
+ tr: isTop,
544
+ bl: isBottom,
545
+ br: isBottom
546
+ };
547
+
548
+ const pathD = getRoundedPath(x, segmentY, barWidth, segmentHeight, barRadius, corners);
549
+
432
550
  const rect = (
433
- <rect
551
+ <path
434
552
  key={`${d.id}-${i}`}
435
- x={x}
436
- y={segmentY}
437
- width={barWidth}
438
- height={segmentHeight}
553
+ d={pathD}
439
554
  fill={stack.color}
440
- rx={barRadius}
441
555
  opacity={barOpacity}
442
556
  stroke={isSelected ? '#fff' : 'none'}
443
557
  strokeWidth={isSelected ? 1 : 0}
@@ -0,0 +1,40 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react';
2
+ import { describe, it, expect, vi } from 'vitest';
3
+ import { SmartBarChart } from '../SmartBarChart';
4
+
5
+ describe('SmartBarChart Navigation', () => {
6
+ const mockData = Array.from({ length: 24 }, (_, i) => {
7
+ const year = 2023 + Math.floor(i / 12);
8
+ const month = (i % 12) + 1;
9
+ return {
10
+ date: `${year}-${String(month).padStart(2, '0')}-01`,
11
+ value: 100,
12
+ label: `Item ${i}`
13
+ };
14
+ });
15
+
16
+ it('enables prev button when data exceeds visible count', () => {
17
+ render(<SmartBarChart data={mockData} view="month" />); // VISIBLE_COUNT = 12
18
+ // mockData has 24 items.
19
+ // Initial window: last 12 items (indices 12-23).
20
+ // Prev button should be enabled because we can go back to indices 0-11.
21
+
22
+ const prevBtn = screen.getByText('<');
23
+ const nextBtn = screen.getByText('>');
24
+
25
+ expect(prevBtn).not.toBeDisabled();
26
+ expect(nextBtn).toBeDisabled(); // Already at the end (newest)
27
+ });
28
+
29
+ it('updates window offset when prev button is clicked', () => {
30
+ render(<SmartBarChart data={mockData} view="month" />);
31
+ const prevBtn = screen.getByText('<');
32
+
33
+ fireEvent.click(prevBtn);
34
+ // Window moves back by 1.
35
+
36
+ // Next button should now be enabled
37
+ const nextBtn = screen.getByText('>');
38
+ expect(nextBtn).not.toBeDisabled();
39
+ });
40
+ });
@@ -28,8 +28,8 @@ export const Bar: React.FC<BarProps> = ({
28
28
 
29
29
  return (
30
30
  <motion.rect
31
- initial={{ height: 0, y: y + height }}
32
- animate={{ height, y }}
31
+ initial={{ height: 0, y: y + height, x }}
32
+ animate={{ height, y, x }}
33
33
  transition={{ type: 'spring', stiffness: 300, damping: 30 }}
34
34
  width={width}
35
35
  fill={fillColor}